Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9aad74df6 | |||
| e768f53ccc | |||
| b0e2e112fc | |||
| 28eba8a3ea | |||
| 7eb6a4db78 | |||
| 7dc03f3bc0 | |||
| 8d91bbeb31 | |||
| 182f8bf74c | |||
| a88567fea6 | |||
| e53cea7a82 | |||
| e88ea7917f | |||
| 99585a1c0e | |||
| d327a5146c | |||
| 4276ca890b |
@@ -169,6 +169,48 @@ public class AuthServiceTests
|
|||||||
um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once);
|
um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_LinkedMember_ReturnsMemberInfo()
|
||||||
|
{
|
||||||
|
var db = BuildDb();
|
||||||
|
db.Members.Add(new Member
|
||||||
|
{
|
||||||
|
Id = 7,
|
||||||
|
NickName = "Johnny",
|
||||||
|
FirstName_en = "John",
|
||||||
|
LastName_en = "Chen",
|
||||||
|
LastName_zh = "陳",
|
||||||
|
CreatedBy = "seed",
|
||||||
|
UpdatedBy = "seed",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = 7 };
|
||||||
|
var um = BuildUserManager(findResult: user);
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var sut = BuildSut(um, ts, db);
|
||||||
|
|
||||||
|
var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
|
||||||
|
|
||||||
|
Assert.NotNull(response.User.MemberInfo);
|
||||||
|
Assert.Equal(7, response.User.MemberInfo!.Id);
|
||||||
|
Assert.Equal("Johnny", response.User.MemberInfo.NickName);
|
||||||
|
Assert.Equal("Chen", response.User.MemberInfo.LastName_en);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_AdminOnlyAccount_ReturnsNullMemberInfo()
|
||||||
|
{
|
||||||
|
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = null };
|
||||||
|
var um = BuildUserManager(findResult: user);
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var sut = BuildSut(um, ts, BuildDb());
|
||||||
|
|
||||||
|
var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
|
||||||
|
|
||||||
|
Assert.Null(response.User.MemberInfo);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Refresh tests
|
// Refresh tests
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class MealAttendanceServiceTests
|
||||||
|
{
|
||||||
|
// MealAttendance is auditable, so the InMemory provider requires CreatedBy/UpdatedBy
|
||||||
|
// to be set before insert. Wire in the AuditSaveChangesInterceptor (as the other
|
||||||
|
// service tests do) so those columns are stamped automatically on SaveChanges.
|
||||||
|
private static AppDbContext BuildDb()
|
||||||
|
{
|
||||||
|
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") };
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
||||||
|
var mock = new Mock<IHttpContextAccessor>();
|
||||||
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(
|
||||||
|
new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetCountsAsync_CreatesRowWhenMissing_AndReturnsTotals()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MealAttendanceService(db);
|
||||||
|
var date = new DateOnly(2026, 5, 31);
|
||||||
|
|
||||||
|
var result = await svc.SetCountsAsync(date, adult: 40, youth: 12, kid: 8);
|
||||||
|
|
||||||
|
Assert.Equal("2026-05-31", result.Date);
|
||||||
|
Assert.Equal(40, result.Adult);
|
||||||
|
Assert.Equal(12, result.Youth);
|
||||||
|
Assert.Equal(8, result.Kid);
|
||||||
|
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetCountsAsync_OverwritesExistingRow_AndClampsNegativesToZero()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MealAttendanceService(db);
|
||||||
|
var date = new DateOnly(2026, 5, 31);
|
||||||
|
await svc.SetCountsAsync(date, 40, 12, 8);
|
||||||
|
|
||||||
|
var result = await svc.SetCountsAsync(date, adult: 50, youth: -3, kid: 0);
|
||||||
|
|
||||||
|
Assert.Equal(50, result.Adult);
|
||||||
|
Assert.Equal(0, result.Youth); // negative clamped to zero
|
||||||
|
Assert.Equal(0, result.Kid);
|
||||||
|
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date)); // still one row
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ROLAC.API.Services.Notifications;
|
using ROLAC.API.Services.Notifications;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -8,6 +7,14 @@ namespace ROLAC.API.Tests.Services.Notifications;
|
|||||||
|
|
||||||
public class LineMessageChannelTests
|
public class LineMessageChannelTests
|
||||||
{
|
{
|
||||||
|
// Stub settings provider returning fixed SMTP/Line values for the channel under test.
|
||||||
|
private sealed class StubSettings : INotificationSettingsService
|
||||||
|
{
|
||||||
|
public SmtpOptions GetSmtp() => new();
|
||||||
|
public LineOptions GetLine() => new() { ChannelAccessToken = "tok", ChannelSecret = "sec" };
|
||||||
|
public void Reload() { }
|
||||||
|
}
|
||||||
|
|
||||||
// Captures the outgoing request and returns a canned response.
|
// Captures the outgoing request and returns a canned response.
|
||||||
private sealed class CapturingHandler : HttpMessageHandler
|
private sealed class CapturingHandler : HttpMessageHandler
|
||||||
{
|
{
|
||||||
@@ -28,8 +35,7 @@ public class LineMessageChannelTests
|
|||||||
private static LineMessageChannel BuildChannel(CapturingHandler handler)
|
private static LineMessageChannel BuildChannel(CapturingHandler handler)
|
||||||
{
|
{
|
||||||
var http = new HttpClient(handler);
|
var http = new HttpClient(handler);
|
||||||
var options = Options.Create(new LineOptions { ChannelAccessToken = "tok", ChannelSecret = "sec" });
|
return new LineMessageChannel(http, new StubSettings());
|
||||||
return new LineMessageChannel(http, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -164,4 +164,27 @@ public class OfferingSessionServiceTests
|
|||||||
Assert.Equal("PP-456", line.PayPalTransactionId);
|
Assert.Equal("PP-456", line.PayPalTransactionId);
|
||||||
Assert.Equal("C-789", line.CheckNumber);
|
Assert.Equal("C-789", line.CheckNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPagedAsync_IncludesSundayAttendanceTotal_WhenRowExists()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var catId = await SeedCategoryAsync(db);
|
||||||
|
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||||
|
|
||||||
|
var withDate = new DateOnly(2026, 5, 31);
|
||||||
|
var withoutDate = new DateOnly(2026, 5, 24);
|
||||||
|
await svc.CreateAsync(BuildRequest(catId, withDate));
|
||||||
|
await svc.CreateAsync(BuildRequest(catId, withoutDate));
|
||||||
|
db.MealAttendances.Add(new MealAttendance
|
||||||
|
{ AttendanceDate = withDate, AdultCount = 40, YouthCount = 12, KidCount = 8 });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var page = await svc.GetPagedAsync(1, 20, null, null);
|
||||||
|
|
||||||
|
var withItem = page.Items.Single(i => i.SessionDate == "2026-05-31");
|
||||||
|
var withoutItem = page.Items.Single(i => i.SessionDate == "2026-05-24");
|
||||||
|
Assert.Equal(60, withItem.SundayAttendanceCount); // 40 + 12 + 8
|
||||||
|
Assert.Null(withoutItem.SundayAttendanceCount); // no attendance row -> null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public static class Modules
|
|||||||
public const string Permissions = "Permissions";
|
public const string Permissions = "Permissions";
|
||||||
public const string SystemLogs = "SystemLogs";
|
public const string SystemLogs = "SystemLogs";
|
||||||
public const string AuditLogs = "AuditLogs";
|
public const string AuditLogs = "AuditLogs";
|
||||||
|
public const string Settings = "Settings";
|
||||||
|
|
||||||
/// <summary>All modules, in display order — drives the admin matrix UI.</summary>
|
/// <summary>All modules, in display order — drives the admin matrix UI.</summary>
|
||||||
public static readonly IReadOnlyList<string> All =
|
public static readonly IReadOnlyList<string> All =
|
||||||
@@ -43,6 +44,7 @@ public static class Modules
|
|||||||
Permissions,
|
Permissions,
|
||||||
SystemLogs,
|
SystemLogs,
|
||||||
AuditLogs,
|
AuditLogs,
|
||||||
|
Settings,
|
||||||
];
|
];
|
||||||
|
|
||||||
public static bool IsValid(string module) => All.Contains(module);
|
public static bool IsValid(string module) => All.Contains(module);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using ROLAC.API.DTOs.Auth;
|
using ROLAC.API.DTOs.Auth;
|
||||||
|
using ROLAC.API.DTOs.Invitations;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -16,13 +17,16 @@ public class AuthController : ControllerBase
|
|||||||
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
|
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||||
|
|
||||||
private readonly IAuthService _authService;
|
private readonly IAuthService _authService;
|
||||||
|
private readonly IInvitationService _invitations;
|
||||||
private readonly UserManager<AppUser> _userManager;
|
private readonly UserManager<AppUser> _userManager;
|
||||||
private readonly IWebHostEnvironment _env;
|
private readonly IWebHostEnvironment _env;
|
||||||
|
|
||||||
public AuthController(
|
public AuthController(
|
||||||
IAuthService authService, UserManager<AppUser> userManager, IWebHostEnvironment env)
|
IAuthService authService, IInvitationService invitations,
|
||||||
|
UserManager<AppUser> userManager, IWebHostEnvironment env)
|
||||||
{
|
{
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
|
_invitations = invitations;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_env = env;
|
_env = env;
|
||||||
}
|
}
|
||||||
@@ -186,6 +190,45 @@ public class AuthController : ControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/auth/invitation/validate?token=...
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether an invitation token can still be used. Anonymous so the public
|
||||||
|
/// "set your password" page can decide what to show before the member types anything.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("invitation/validate")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(ValidateInvitationResult), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> ValidateInvitation([FromQuery] string token)
|
||||||
|
=> Ok(await _invitations.ValidateAsync(token));
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/auth/accept-invitation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Consumes an invitation: sets the account password and, on success, logs the member in
|
||||||
|
/// (issues the access token + refresh cookie) so first login lands straight on the portal.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("accept-invitation")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> AcceptInvitation([FromBody] AcceptInvitationRequest request)
|
||||||
|
{
|
||||||
|
var (user, error) = await _invitations.AcceptAsync(request.Token, request.NewPassword);
|
||||||
|
if (user is null)
|
||||||
|
return BadRequest(new { message = error });
|
||||||
|
|
||||||
|
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
var device = Request.Headers.UserAgent.FirstOrDefault();
|
||||||
|
var (response, raw) = await _authService.IssueSessionAsync(user, ip, device);
|
||||||
|
SetRefreshCookie(raw);
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Private helpers
|
// Private helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.DTOs.Invitations;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin endpoints for generating and e-mailing first-login invitation links.
|
||||||
|
/// The public consume/validate endpoints live on <see cref="AuthController"/> so they can set the
|
||||||
|
/// refresh-token cookie and stay anonymous.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/invitations")]
|
||||||
|
[Authorize]
|
||||||
|
public class InvitationsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IInvitationService _invitations;
|
||||||
|
public InvitationsController(IInvitationService invitations) => _invitations = invitations;
|
||||||
|
|
||||||
|
/// <summary>POST /api/invitations — generate a link for a member; returns { token, expiresAt }.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateInvitationRequest request)
|
||||||
|
{
|
||||||
|
try { return Ok(await _invitations.CreateAsync(request)); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST /api/invitations/send — e-mail an already-generated link to the member.</summary>
|
||||||
|
[HttpPost("send")]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> Send([FromBody] SendInvitationRequest request)
|
||||||
|
{
|
||||||
|
try { await _invitations.SendEmailAsync(request.MemberId, request.Link); return NoContent(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ using System.Text;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ROLAC.API.DTOs.Notifications;
|
using ROLAC.API.DTOs.Notifications;
|
||||||
using ROLAC.API.Services.Notifications;
|
using ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
@@ -22,14 +21,14 @@ public sealed class LineWebhookController : ControllerBase
|
|||||||
|
|
||||||
private readonly ILineNotificationService _line;
|
private readonly ILineNotificationService _line;
|
||||||
private readonly IMessageChannel _channel;
|
private readonly IMessageChannel _channel;
|
||||||
private readonly LineOptions _options;
|
private readonly INotificationSettingsService _settings;
|
||||||
|
|
||||||
public LineWebhookController(
|
public LineWebhookController(
|
||||||
ILineNotificationService line, IMessageChannel channel, IOptions<LineOptions> options)
|
ILineNotificationService line, IMessageChannel channel, INotificationSettingsService settings)
|
||||||
{
|
{
|
||||||
_line = line;
|
_line = line;
|
||||||
_channel = channel;
|
_channel = channel;
|
||||||
_options = options.Value;
|
_settings = settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("webhook")]
|
[HttpPost("webhook")]
|
||||||
@@ -40,7 +39,7 @@ public sealed class LineWebhookController : ControllerBase
|
|||||||
var rawBody = await reader.ReadToEndAsync(ct);
|
var rawBody = await reader.ReadToEndAsync(ct);
|
||||||
var signature = Request.Headers["X-Line-Signature"].FirstOrDefault();
|
var signature = Request.Headers["X-Line-Signature"].FirstOrDefault();
|
||||||
|
|
||||||
if (!LineSignature.IsValid(_options.ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
|
if (!LineSignature.IsValid(_settings.GetLine().ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
|
||||||
return BadRequest();
|
return BadRequest();
|
||||||
|
|
||||||
var payload = JsonSerializer.Deserialize<LineWebhookPayload>(rawBody, JsonOpts);
|
var payload = JsonSerializer.Deserialize<LineWebhookPayload>(rawBody, JsonOpts);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.DTOs.MealAttendance;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
namespace ROLAC.API.Controllers;
|
namespace ROLAC.API.Controllers;
|
||||||
@@ -23,4 +24,10 @@ public class MealAttendanceController : ControllerBase
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to)
|
public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to)
|
||||||
=> Ok(await _svc.GetRangeAsync(from, to));
|
=> Ok(await _svc.GetRangeAsync(from, to));
|
||||||
|
|
||||||
|
/// <summary>Overwrite a specific Sunday's counts (back-office editor). Authenticated only.</summary>
|
||||||
|
[HttpPut("{date}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> SetCounts(DateOnly date, [FromBody] SetAttendanceRequest body)
|
||||||
|
=> Ok(await _svc.SetCountsAsync(date, body.Adult, body.Youth, body.Kid));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ public class OfferingEntryController : ControllerBase
|
|||||||
NickName = request.NickName,
|
NickName = request.NickName,
|
||||||
FirstName_zh = request.FirstName_zh,
|
FirstName_zh = request.FirstName_zh,
|
||||||
LastName_zh = request.LastName_zh,
|
LastName_zh = request.LastName_zh,
|
||||||
|
Entity = request.Entity,
|
||||||
PhoneCell = request.PhoneCell,
|
PhoneCell = request.PhoneCell,
|
||||||
Status = "Visitor",
|
Status = "Visitor",
|
||||||
Country = "USA",
|
Country = "USA",
|
||||||
@@ -73,6 +74,7 @@ public class OfferingEntryController : ControllerBase
|
|||||||
{
|
{
|
||||||
Id = id, NickName = request.NickName,
|
Id = id, NickName = request.NickName,
|
||||||
FirstName_en = request.FirstName_en, LastName_en = request.LastName_en,
|
FirstName_en = request.FirstName_en, LastName_en = request.LastName_en,
|
||||||
|
Entity = request.Entity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.DTOs.Settings;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site-wide and notification (SMTP/Line) settings, surfaced by the Church Profile → Site /
|
||||||
|
/// Notification tabs. Gated by the <c>Settings</c> permission module (super_admin bypasses).
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/settings")]
|
||||||
|
[Authorize]
|
||||||
|
public class SettingsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settings;
|
||||||
|
private readonly IEmailService _email;
|
||||||
|
private readonly ILineNotificationService _line;
|
||||||
|
private readonly CurrentUserAccessor _currentUser;
|
||||||
|
|
||||||
|
public SettingsController(
|
||||||
|
ISettingsService settings,
|
||||||
|
IEmailService email,
|
||||||
|
ILineNotificationService line,
|
||||||
|
CurrentUserAccessor currentUser)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
_email = email;
|
||||||
|
_line = line;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Site settings ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpGet("site")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Read)]
|
||||||
|
public async Task<IActionResult> GetSite() => Ok(await _settings.GetSiteAsync());
|
||||||
|
|
||||||
|
[HttpPut("site")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> UpdateSite([FromBody] UpdateSiteSettingRequest request)
|
||||||
|
{
|
||||||
|
await _settings.UpdateSiteAsync(request);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notification settings ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpGet("notification")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Read)]
|
||||||
|
public async Task<IActionResult> GetNotification()
|
||||||
|
{
|
||||||
|
var dto = await _settings.GetNotificationAsync();
|
||||||
|
dto.WebhookUrl = $"{Request.Scheme}://{Request.Host}/api/line/webhook";
|
||||||
|
return Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("notification")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> UpdateNotification([FromBody] UpdateNotificationSettingRequest request)
|
||||||
|
{
|
||||||
|
await _settings.UpdateNotificationAsync(request);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("notification/test-email")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> TestEmail([FromBody] TestEmailRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var to = string.IsNullOrWhiteSpace(request.ToAddress) ? _currentUser.Email : request.ToAddress;
|
||||||
|
if (string.IsNullOrWhiteSpace(to))
|
||||||
|
return BadRequest(new { message = "No recipient — provide an address or set an email on your account." });
|
||||||
|
|
||||||
|
var result = await _email.SendAsync(new EmailMessage(
|
||||||
|
MemberIds: Array.Empty<int>(),
|
||||||
|
Addresses: new[] { to },
|
||||||
|
Subject: "ROLAC test email / 測試郵件",
|
||||||
|
HtmlBody: "<p>This is a test email from ROLAC notification settings.</p>"
|
||||||
|
+ "<p>這是來自 ROLAC 通知設定的測試郵件。</p>",
|
||||||
|
SentByUserId: _currentUser.UserIdOrSystem), ct);
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("notification/test-line")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> TestLine([FromBody] TestLineRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (request.MemberId is null && request.GroupId is null)
|
||||||
|
return BadRequest(new { message = "Choose a bound member or group to receive the test." });
|
||||||
|
|
||||||
|
var result = await _line.SendLineAsync(
|
||||||
|
body: "ROLAC 測試訊息 / This is a test Line message from ROLAC.",
|
||||||
|
memberIds: request.MemberId is { } m ? new[] { m } : Array.Empty<int>(),
|
||||||
|
groupIds: request.GroupId is { } g ? new[] { g } : Array.Empty<int>(),
|
||||||
|
sentByUserId: _currentUser.UserIdOrSystem,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,4 +25,22 @@ public class UserInfo
|
|||||||
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
|
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, ModuleActions> Permissions { get; set; } = [];
|
public Dictionary<string, ModuleActions> Permissions { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The church member linked to this login account, or null for admin-only
|
||||||
|
/// accounts (no MemberId) and accounts whose member record was deleted.
|
||||||
|
/// Lets the SPA greet the user by their real name.
|
||||||
|
/// </summary>
|
||||||
|
public MemberInfo? MemberInfo { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Minimal member identity for greeting the signed-in user.</summary>
|
||||||
|
public class MemberInfo
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? NickName { get; set; }
|
||||||
|
public string FirstName_en { get; set; } = "";
|
||||||
|
public string LastName_en { get; set; } = "";
|
||||||
|
public string? FirstName_zh { get; set; }
|
||||||
|
public string? LastName_zh { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ public class ChurchProfileDto
|
|||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
|
public string? NameZh { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Website { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? City { get; set; }
|
public string? City { get; set; }
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
@@ -18,6 +22,10 @@ public class ChurchProfileDto
|
|||||||
public class UpdateChurchProfileRequest
|
public class UpdateChurchProfileRequest
|
||||||
{
|
{
|
||||||
[Required, MaxLength(200)] public string Name { get; set; } = "";
|
[Required, MaxLength(200)] public string Name { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? NameZh { get; set; }
|
||||||
|
[MaxLength(50)] public string? Phone { get; set; }
|
||||||
|
[MaxLength(200), EmailAddress] public string? Email { get; set; }
|
||||||
|
[MaxLength(300)] public string? Website { get; set; }
|
||||||
[MaxLength(500)] public string? Address { get; set; }
|
[MaxLength(500)] public string? Address { get; set; }
|
||||||
[MaxLength(100)] public string? City { get; set; }
|
[MaxLength(100)] public string? City { get; set; }
|
||||||
[MaxLength(50)] public string? State { get; set; }
|
[MaxLength(50)] public string? State { get; set; }
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ public class MemberTypeaheadDto
|
|||||||
public string? NickName { get; set; }
|
public string? NickName { get; set; }
|
||||||
public string FirstName_en { get; set; } = "";
|
public string FirstName_en { get; set; } = "";
|
||||||
public string LastName_en { get; set; } = "";
|
public string LastName_en { get; set; } = "";
|
||||||
|
public string? Entity { get; set; } // company / business name (公司行號), if any
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ public class OfferingSessionListItemDto
|
|||||||
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; }
|
public bool HasProof { get; set; }
|
||||||
|
public int? SundayAttendanceCount { get; set; } // null = no attendance recorded for the date
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ public class QuickAddMemberRequest
|
|||||||
[MaxLength(100)] public string? NickName { get; set; }
|
[MaxLength(100)] public string? NickName { get; set; }
|
||||||
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
||||||
[MaxLength(100)] public string? LastName_zh { get; set; }
|
[MaxLength(100)] public string? LastName_zh { get; set; }
|
||||||
|
[MaxLength(200)] public string? Entity { get; set; }
|
||||||
[MaxLength(30)] public string? PhoneCell { get; set; }
|
[MaxLength(30)] public string? PhoneCell { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace ROLAC.API.DTOs.Invitations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin request to generate a first-login invitation link for a member. If the member has no
|
||||||
|
/// account yet, one is auto-created (no password) using <see cref="Email"/> or the member's email.
|
||||||
|
/// </summary>
|
||||||
|
public class CreateInvitationRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public int MemberId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional override for the login email when the member has none on file.</summary>
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Roles to assign when an account is created. Defaults to ["member"].</summary>
|
||||||
|
public List<string>? Roles { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Result of generating an invitation — the raw token is returned ONCE.</summary>
|
||||||
|
public class CreateInvitationResult
|
||||||
|
{
|
||||||
|
public string Token { get; set; } = null!;
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Admin request to e-mail an already-generated invitation link to the member.</summary>
|
||||||
|
public class SendInvitationRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public int MemberId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Link { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Public result describing whether an invitation token can still be used.</summary>
|
||||||
|
public class ValidateInvitationResult
|
||||||
|
{
|
||||||
|
public bool Valid { get; set; }
|
||||||
|
public bool Expired { get; set; }
|
||||||
|
public string? MemberName { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Public request to consume an invitation and set the account password.</summary>
|
||||||
|
public class AcceptInvitationRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string Token { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(128, MinimumLength = 8)]
|
||||||
|
public string NewPassword { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ROLAC.API.DTOs.MealAttendance;
|
||||||
|
|
||||||
|
/// <summary>Absolute head-counts to write for one Sunday, from the back-office editor.</summary>
|
||||||
|
public class SetAttendanceRequest
|
||||||
|
{
|
||||||
|
public int Adult { get; set; }
|
||||||
|
public int Youth { get; set; }
|
||||||
|
public int Kid { get; set; }
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ public class CreateMemberRequest
|
|||||||
[MaxLength(100)] public string? NickName { get; set; }
|
[MaxLength(100)] public string? NickName { get; set; }
|
||||||
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
||||||
[MaxLength(100)] public string? LastName_zh { get; set; }
|
[MaxLength(100)] public string? LastName_zh { get; set; }
|
||||||
|
[MaxLength(200)] public string? Entity { get; set; }
|
||||||
[MaxLength(10)] public string? Gender { get; set; }
|
[MaxLength(10)] public string? Gender { get; set; }
|
||||||
public DateOnly? DateOfBirth { get; set; }
|
public DateOnly? DateOfBirth { get; set; }
|
||||||
public DateOnly? BaptismDate { get; set; }
|
public DateOnly? BaptismDate { get; set; }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public class MemberListItemDto
|
|||||||
public string? NickName { get; set; }
|
public string? NickName { get; set; }
|
||||||
public string? FirstName_zh { get; set; }
|
public string? FirstName_zh { get; set; }
|
||||||
public string? LastName_zh { get; set; }
|
public string? LastName_zh { get; set; }
|
||||||
|
public string? Entity { get; set; }
|
||||||
public string Status { get; set; } = "";
|
public string Status { get; set; } = "";
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? PhoneCell { get; set; }
|
public string? PhoneCell { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Settings;
|
||||||
|
|
||||||
|
// ── Site settings ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class SiteSettingDto
|
||||||
|
{
|
||||||
|
public string SiteTitle { get; set; } = "";
|
||||||
|
public string? SiteTitleZh { get; set; }
|
||||||
|
public string DefaultLanguage { get; set; } = "en";
|
||||||
|
public string TimeZone { get; set; } = "";
|
||||||
|
public string DateFormat { get; set; } = "";
|
||||||
|
public string Currency { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateSiteSettingRequest
|
||||||
|
{
|
||||||
|
[Required, MaxLength(200)] public string SiteTitle { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? SiteTitleZh { get; set; }
|
||||||
|
[Required, MaxLength(10)] public string DefaultLanguage { get; set; } = "en";
|
||||||
|
[Required, MaxLength(100)] public string TimeZone { get; set; } = "";
|
||||||
|
[Required, MaxLength(50)] public string DateFormat { get; set; } = "";
|
||||||
|
[Required, MaxLength(10)] public string Currency { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notification settings ──────────────────────────────────────────────────
|
||||||
|
// Secrets are never returned. The DTO exposes only whether each secret is configured; the UI
|
||||||
|
// shows a write-only field where a blank value on update means "keep the stored secret".
|
||||||
|
|
||||||
|
public class NotificationSettingDto
|
||||||
|
{
|
||||||
|
public bool EnableEmail { get; set; }
|
||||||
|
public string SmtpHost { get; set; } = "";
|
||||||
|
public int SmtpPort { get; set; }
|
||||||
|
public bool SmtpUseSsl { get; set; }
|
||||||
|
public string SmtpUser { get; set; } = "";
|
||||||
|
public string FromAddress { get; set; } = "";
|
||||||
|
public string FromName { get; set; } = "";
|
||||||
|
public bool HasSmtpPassword { get; set; }
|
||||||
|
|
||||||
|
public bool EnableLine { get; set; }
|
||||||
|
public bool HasLineChannelAccessToken { get; set; }
|
||||||
|
public bool HasLineChannelSecret { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Read-only webhook URL to register in the Line console (derived from the request).</summary>
|
||||||
|
public string WebhookUrl { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateNotificationSettingRequest
|
||||||
|
{
|
||||||
|
public bool EnableEmail { get; set; }
|
||||||
|
[MaxLength(200)] public string SmtpHost { get; set; } = "";
|
||||||
|
[Range(0, 65535)] public int SmtpPort { get; set; } = 587;
|
||||||
|
public bool SmtpUseSsl { get; set; } = true;
|
||||||
|
[MaxLength(200)] public string SmtpUser { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? FromAddress { get; set; }
|
||||||
|
[MaxLength(200)] public string? FromName { get; set; }
|
||||||
|
/// <summary>Blank = keep the stored password unchanged.</summary>
|
||||||
|
[MaxLength(500)] public string? SmtpPassword { get; set; }
|
||||||
|
|
||||||
|
public bool EnableLine { get; set; }
|
||||||
|
/// <summary>Blank = keep the stored token unchanged.</summary>
|
||||||
|
[MaxLength(500)] public string? LineChannelAccessToken { get; set; }
|
||||||
|
/// <summary>Blank = keep the stored secret unchanged.</summary>
|
||||||
|
[MaxLength(200)] public string? LineChannelSecret { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test-send requests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class TestEmailRequest
|
||||||
|
{
|
||||||
|
/// <summary>Optional override; defaults to the current user's email when omitted.</summary>
|
||||||
|
[MaxLength(200), EmailAddress] public string? ToAddress { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TestLineRequest
|
||||||
|
{
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
public int? GroupId { get; set; }
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
{
|
{
|
||||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||||
|
|
||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
|
public DbSet<UserInvitation> UserInvitations => Set<UserInvitation>();
|
||||||
public DbSet<Member> Members => Set<Member>();
|
public DbSet<Member> Members => Set<Member>();
|
||||||
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
|
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
|
||||||
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
|
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
|
||||||
@@ -32,6 +33,9 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
|
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
|
||||||
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
|
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
|
||||||
|
|
||||||
|
public DbSet<SiteSetting> SiteSettings => Set<SiteSetting>();
|
||||||
|
public DbSet<NotificationSetting> NotificationSettings => Set<NotificationSetting>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(builder);
|
base.OnModelCreating(builder);
|
||||||
@@ -53,6 +57,23 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Ignore(e => e.IsActive);
|
entity.Ignore(e => e.IsActive);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── UserInvitation (single-use, expiring first-login links) ─────────
|
||||||
|
builder.Entity<UserInvitation>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => e.TokenHash).IsUnique();
|
||||||
|
entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired();
|
||||||
|
entity.Property(e => e.UserId).HasMaxLength(450).IsRequired();
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450).IsRequired();
|
||||||
|
entity.HasIndex(e => e.UserId);
|
||||||
|
entity.HasOne(e => e.User).WithMany()
|
||||||
|
.HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.Ignore(e => e.IsExpired);
|
||||||
|
entity.Ignore(e => e.IsUsed);
|
||||||
|
entity.Ignore(e => e.IsRevoked);
|
||||||
|
entity.Ignore(e => e.IsActive);
|
||||||
|
});
|
||||||
|
|
||||||
// ── AppUser (unchanged + new unique index on MemberId) ──────────────
|
// ── AppUser (unchanged + new unique index on MemberId) ──────────────
|
||||||
builder.Entity<AppUser>(entity =>
|
builder.Entity<AppUser>(entity =>
|
||||||
{
|
{
|
||||||
@@ -97,6 +118,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.NickName).HasMaxLength(100);
|
entity.Property(e => e.NickName).HasMaxLength(100);
|
||||||
entity.Property(e => e.FirstName_zh).HasMaxLength(100);
|
entity.Property(e => e.FirstName_zh).HasMaxLength(100);
|
||||||
entity.Property(e => e.LastName_zh).HasMaxLength(100);
|
entity.Property(e => e.LastName_zh).HasMaxLength(100);
|
||||||
|
entity.Property(e => e.Entity).HasMaxLength(200);
|
||||||
entity.Property(e => e.Gender).HasMaxLength(10);
|
entity.Property(e => e.Gender).HasMaxLength(10);
|
||||||
entity.Property(e => e.BaptismChurch).HasMaxLength(200);
|
entity.Property(e => e.BaptismChurch).HasMaxLength(200);
|
||||||
entity.Property(e => e.Email).HasMaxLength(200);
|
entity.Property(e => e.Email).HasMaxLength(200);
|
||||||
@@ -245,12 +267,43 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.BankName).HasMaxLength(200);
|
entity.Property(e => e.BankName).HasMaxLength(200);
|
||||||
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
|
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
|
||||||
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
|
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.NameZh).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.Phone).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.Email).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.Website).HasMaxLength(300);
|
||||||
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);
|
||||||
// Optimistic-concurrency token for safe check-number allocation.
|
// Optimistic-concurrency token for safe check-number allocation.
|
||||||
entity.Property(e => e.xmin).IsRowVersion();
|
entity.Property(e => e.xmin).IsRowVersion();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── SiteSetting (singleton presentation/locale settings) ─────────────
|
||||||
|
builder.Entity<SiteSetting>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.SiteTitle).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(e => e.SiteTitleZh).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.DefaultLanguage).HasMaxLength(10).IsRequired();
|
||||||
|
entity.Property(e => e.TimeZone).HasMaxLength(100).IsRequired();
|
||||||
|
entity.Property(e => e.DateFormat).HasMaxLength(50).IsRequired();
|
||||||
|
entity.Property(e => e.Currency).HasMaxLength(10).IsRequired();
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── NotificationSetting (singleton SMTP + Line settings) ─────────────
|
||||||
|
builder.Entity<NotificationSetting>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.SmtpHost).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.SmtpUser).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.SmtpPassword).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.FromAddress).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.FromName).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.LineChannelAccessToken).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.LineChannelSecret).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Check (disbursement) ─────────────────────────────────────────────
|
// ── Check (disbursement) ─────────────────────────────────────────────
|
||||||
builder.Entity<Check>(entity =>
|
builder.Entity<Check>(entity =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -208,6 +208,50 @@ public static class DbSeeder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task SeedSiteSettingAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
// Singleton row holding site-wide presentation/locale settings.
|
||||||
|
if (!await db.SiteSettings.AnyAsync())
|
||||||
|
{
|
||||||
|
db.SiteSettings.Add(new SiteSetting
|
||||||
|
{
|
||||||
|
SiteTitle = "River Of Life Christian Church",
|
||||||
|
SiteTitleZh = "生命河靈糧堂",
|
||||||
|
DefaultLanguage = "en",
|
||||||
|
TimeZone = "America/Los_Angeles",
|
||||||
|
DateFormat = "yyyy-MM-dd",
|
||||||
|
Currency = "USD",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task SeedNotificationSettingAsync(AppDbContext db, IConfiguration config)
|
||||||
|
{
|
||||||
|
// Singleton row that becomes the runtime source of truth for SMTP + Line. Seed it once
|
||||||
|
// from the legacy "Smtp"/"Line" appsettings sections so existing config carries over.
|
||||||
|
if (!await db.NotificationSettings.AnyAsync())
|
||||||
|
{
|
||||||
|
var smtp = config.GetSection("Smtp");
|
||||||
|
var line = config.GetSection("Line");
|
||||||
|
db.NotificationSettings.Add(new NotificationSetting
|
||||||
|
{
|
||||||
|
EnableEmail = !string.IsNullOrWhiteSpace(smtp["Host"]),
|
||||||
|
SmtpHost = smtp["Host"] ?? "",
|
||||||
|
SmtpPort = int.TryParse(smtp["Port"], out var port) ? port : 587,
|
||||||
|
SmtpUseSsl = !bool.TryParse(smtp["UseSsl"], out var ssl) || ssl,
|
||||||
|
SmtpUser = smtp["User"] ?? "",
|
||||||
|
SmtpPassword = smtp["Password"] ?? "",
|
||||||
|
FromAddress = smtp["FromAddress"] ?? "",
|
||||||
|
FromName = smtp["FromName"] ?? "",
|
||||||
|
EnableLine = !string.IsNullOrWhiteSpace(line["ChannelAccessToken"]),
|
||||||
|
LineChannelAccessToken = line["ChannelAccessToken"] ?? "",
|
||||||
|
LineChannelSecret = line["ChannelSecret"] ?? "",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds roles and (in Development) the default admin account.
|
/// Seeds roles and (in Development) the default admin account.
|
||||||
/// Called once on application startup after migrations have been applied.
|
/// Called once on application startup after migrations have been applied.
|
||||||
@@ -217,6 +261,7 @@ public static class DbSeeder
|
|||||||
var roleManager = services.GetRequiredService<RoleManager<AppRole>>();
|
var roleManager = services.GetRequiredService<RoleManager<AppRole>>();
|
||||||
var userManager = services.GetRequiredService<UserManager<AppUser>>();
|
var userManager = services.GetRequiredService<UserManager<AppUser>>();
|
||||||
var env = services.GetRequiredService<IWebHostEnvironment>();
|
var env = services.GetRequiredService<IWebHostEnvironment>();
|
||||||
|
var config = services.GetRequiredService<IConfiguration>();
|
||||||
|
|
||||||
await SeedRolesAsync(roleManager);
|
await SeedRolesAsync(roleManager);
|
||||||
|
|
||||||
@@ -226,6 +271,8 @@ public static class DbSeeder
|
|||||||
await SeedMinistriesAsync(db);
|
await SeedMinistriesAsync(db);
|
||||||
await SeedExpenseCategoriesAsync(db);
|
await SeedExpenseCategoriesAsync(db);
|
||||||
await SeedChurchProfileAsync(db);
|
await SeedChurchProfileAsync(db);
|
||||||
|
await SeedSiteSettingAsync(db);
|
||||||
|
await SeedNotificationSettingAsync(db, config);
|
||||||
|
|
||||||
if (env.IsDevelopment())
|
if (env.IsDevelopment())
|
||||||
await SeedAdminUserAsync(userManager);
|
await SeedAdminUserAsync(userManager);
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ public class ChurchProfile : AuditableEntity, IAuditable
|
|||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name { get; set; } = null!;
|
public string Name { get; set; } = null!;
|
||||||
|
public string? NameZh { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Website { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? City { get; set; }
|
public string? City { get; set; }
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ public static class AuditActions
|
|||||||
public const string PasswordChanged = "PasswordChanged";
|
public const string PasswordChanged = "PasswordChanged";
|
||||||
public const string UserDeactivated = "UserDeactivated";
|
public const string UserDeactivated = "UserDeactivated";
|
||||||
public const string PermissionChanged = "PermissionChanged";
|
public const string PermissionChanged = "PermissionChanged";
|
||||||
|
public const string InvitationCreated = "InvitationCreated";
|
||||||
|
public const string InvitationAccepted = "InvitationAccepted";
|
||||||
public const string CheckIssued = "CheckIssued";
|
public const string CheckIssued = "CheckIssued";
|
||||||
public const string CheckVoided = "CheckVoided";
|
public const string CheckVoided = "CheckVoided";
|
||||||
public const string ExpenseApproved = "ExpenseApproved";
|
public const string ExpenseApproved = "ExpenseApproved";
|
||||||
@@ -56,7 +58,8 @@ public static class AuditActions
|
|||||||
public static readonly IReadOnlyList<string> All =
|
public static readonly IReadOnlyList<string> All =
|
||||||
[
|
[
|
||||||
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
|
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
|
||||||
PasswordChanged, UserDeactivated, PermissionChanged, CheckIssued,
|
PasswordChanged, UserDeactivated, PermissionChanged,
|
||||||
|
InvitationCreated, InvitationAccepted, CheckIssued,
|
||||||
CheckVoided, ExpenseApproved, StatementFinalized,
|
CheckVoided, ExpenseApproved, StatementFinalized,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public class Member : SoftDeleteEntity, IAuditable
|
|||||||
public string? NickName { get; set; }
|
public string? NickName { get; set; }
|
||||||
public string? FirstName_zh { get; set; }
|
public string? FirstName_zh { get; set; }
|
||||||
public string? LastName_zh { get; set; }
|
public string? LastName_zh { get; set; }
|
||||||
|
public string? Entity { get; set; } // company / business name (公司行號) — used for company-check offerings
|
||||||
public string? Gender { get; set; } // 'M' | 'F' | 'Other'
|
public string? Gender { get; set; } // 'M' | 'F' | 'Other'
|
||||||
public DateOnly? DateOfBirth { get; set; }
|
public DateOnly? DateOfBirth { get; set; }
|
||||||
public DateOnly? BaptismDate { get; set; }
|
public DateOnly? BaptismDate { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Singleton (Id == 1) holding the editable SMTP + Line notification settings. This row — not the
|
||||||
|
/// "Smtp"/"Line" appsettings sections — is the runtime source of truth; those sections only seed
|
||||||
|
/// this row once on first startup. Read at send time via <c>INotificationSettingsService</c> so
|
||||||
|
/// edits apply without restarting the API.
|
||||||
|
///
|
||||||
|
/// Secrets (<see cref="SmtpPassword"/>, <see cref="LineChannelAccessToken"/>,
|
||||||
|
/// <see cref="LineChannelSecret"/>) are stored plaintext and protected by RBAC (the <c>Settings</c>
|
||||||
|
/// module / super_admin) per the project decision for this small single-VM internal app.
|
||||||
|
/// </summary>
|
||||||
|
public class NotificationSetting : AuditableEntity, IAuditable
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
// ── Email (SMTP) ─────────────────────────────────────────────────────────
|
||||||
|
public bool EnableEmail { get; set; }
|
||||||
|
public string SmtpHost { get; set; } = "";
|
||||||
|
public int SmtpPort { get; set; } = 587;
|
||||||
|
public bool SmtpUseSsl { get; set; } = true; // true → STARTTLS
|
||||||
|
public string SmtpUser { get; set; } = "";
|
||||||
|
public string SmtpPassword { get; set; } = "";
|
||||||
|
public string FromAddress { get; set; } = "";
|
||||||
|
public string FromName { get; set; } = "";
|
||||||
|
|
||||||
|
// ── Line ─────────────────────────────────────────────────────────────────
|
||||||
|
public bool EnableLine { get; set; }
|
||||||
|
public string LineChannelAccessToken { get; set; } = "";
|
||||||
|
public string LineChannelSecret { get; set; } = "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Singleton (Id == 1) holding site-wide presentation and locale settings, edited from the
|
||||||
|
/// Church Profile → Site Settings tab (gated by the <c>Settings</c> permission module).
|
||||||
|
/// Seeded with sensible defaults on startup.
|
||||||
|
/// </summary>
|
||||||
|
public class SiteSetting : AuditableEntity, IAuditable
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string SiteTitle { get; set; } = "";
|
||||||
|
public string? SiteTitleZh { get; set; }
|
||||||
|
public string DefaultLanguage { get; set; } = "en"; // "en" | "zh"
|
||||||
|
public string TimeZone { get; set; } = "America/Los_Angeles";
|
||||||
|
public string DateFormat { get; set; } = "yyyy-MM-dd";
|
||||||
|
public string Currency { get; set; } = "USD";
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single-use, expiring invitation that lets a member set their own password and log in for
|
||||||
|
/// the first time — without an admin-generated temporary password. The raw token is e-mailed /
|
||||||
|
/// copied to the member; only its SHA-256 hash is stored here (same scheme as RefreshToken).
|
||||||
|
/// </summary>
|
||||||
|
public class UserInvitation
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public string UserId { get; set; } = null!;
|
||||||
|
public AppUser User { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>SHA-256 hex of the raw invitation token. Never store raw tokens.</summary>
|
||||||
|
public string TokenHash { get; set; } = null!;
|
||||||
|
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Id of the admin who generated the link.</summary>
|
||||||
|
public string CreatedBy { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>Set when the member consumes the link to set their password (single-use).</summary>
|
||||||
|
public DateTime? UsedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Set when superseded by a newer invitation for the same user (re-issue).</summary>
|
||||||
|
public DateTime? RevokedAt { get; set; }
|
||||||
|
|
||||||
|
// Computed helpers — NOT mapped to DB columns (ignored in OnModelCreating)
|
||||||
|
public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
|
||||||
|
public bool IsUsed => UsedAt.HasValue;
|
||||||
|
public bool IsRevoked => RevokedAt.HasValue;
|
||||||
|
public bool IsActive => !IsUsed && !IsRevoked && !IsExpired;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ROLAC.API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddUserInvitations : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "UserInvitations",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
UserId = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
TokenHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
UsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
RevokedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_UserInvitations", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_UserInvitations_AspNetUsers_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UserInvitations_TokenHash",
|
||||||
|
table: "UserInvitations",
|
||||||
|
column: "TokenHash",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UserInvitations_UserId",
|
||||||
|
table: "UserInvitations",
|
||||||
|
column: "UserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "UserInvitations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -463,14 +463,26 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(450)
|
.HasMaxLength(450)
|
||||||
.HasColumnType("character varying(450)");
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("NameZh")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
b.Property<int>("NextCheckNumber")
|
b.Property<int>("NextCheckNumber")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
b.Property<string>("State")
|
b.Property<string>("State")
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("character varying(50)");
|
.HasColumnType("character varying(50)");
|
||||||
@@ -483,6 +495,10 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(450)
|
.HasMaxLength(450)
|
||||||
.HasColumnType("character varying(450)");
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Website")
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("character varying(300)");
|
||||||
|
|
||||||
b.Property<string>("ZipCode")
|
b.Property<string>("ZipCode")
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
.HasColumnType("character varying(20)");
|
.HasColumnType("character varying(20)");
|
||||||
@@ -1124,6 +1140,10 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Entity")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
b.Property<int?>("FamilyUnitId")
|
b.Property<int?>("FamilyUnitId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -1323,6 +1343,82 @@ namespace ROLAC.API.Migrations
|
|||||||
b.ToTable("MonthlyStatements");
|
b.ToTable("MonthlyStatements");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.NotificationSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<bool>("EnableEmail")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("EnableLine")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("FromAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("FromName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("LineChannelAccessToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("LineChannelSecret")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("SmtpHost")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("SmtpPassword")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<int>("SmtpPort")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("SmtpUseSsl")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("SmtpUser")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("NotificationSettings");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1653,6 +1749,109 @@ namespace ROLAC.API.Migrations
|
|||||||
b.ToTable("RolePermissions");
|
b.ToTable("RolePermissions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.SiteSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Currency")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("DateFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultLanguage")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("SiteTitle")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("SiteTitleZh")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("TimeZone")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("SiteSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.UserInvitation", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UsedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("UserInvitations");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
||||||
@@ -1874,6 +2073,17 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Navigation("Role");
|
b.Navigation("Role");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.UserInvitation", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.AppUser", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("RefreshTokens");
|
b.Navigation("RefreshTokens");
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ builder.Services.AddScoped<ITokenService, TokenService>();
|
|||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
builder.Services.AddScoped<IMemberService, MemberService>();
|
builder.Services.AddScoped<IMemberService, MemberService>();
|
||||||
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
|
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
|
||||||
|
builder.Services.AddScoped<IInvitationService, InvitationService>();
|
||||||
builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
|
builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
|
||||||
builder.Services.AddScoped<IGivingService, GivingService>();
|
builder.Services.AddScoped<IGivingService, GivingService>();
|
||||||
builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>();
|
builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>();
|
||||||
@@ -155,14 +156,19 @@ builder.Services.AddScoped<IExpenseService, ExpenseService>();
|
|||||||
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
|
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
|
||||||
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
|
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
|
||||||
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
|
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
|
||||||
|
builder.Services.AddScoped<ISettingsService, SettingsService>();
|
||||||
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
|
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
|
||||||
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
|
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
|
||||||
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
||||||
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
||||||
|
|
||||||
// ── Notifications (email via SMTP + Line) ──────────────────────────────────
|
// ── Notifications (email via SMTP + Line) ──────────────────────────────────
|
||||||
|
// IOptions binding stays only as the one-time seed/fallback; the runtime source of truth is the
|
||||||
|
// DB-backed NotificationSetting row, read (and hot-reloaded) via INotificationSettingsService.
|
||||||
builder.Services.Configure<ROLAC.API.Services.Notifications.SmtpOptions>(config.GetSection("Smtp"));
|
builder.Services.Configure<ROLAC.API.Services.Notifications.SmtpOptions>(config.GetSection("Smtp"));
|
||||||
builder.Services.Configure<ROLAC.API.Services.Notifications.LineOptions>(config.GetSection("Line"));
|
builder.Services.Configure<ROLAC.API.Services.Notifications.LineOptions>(config.GetSection("Line"));
|
||||||
|
builder.Services.AddSingleton<ROLAC.API.Services.Notifications.INotificationSettingsService,
|
||||||
|
ROLAC.API.Services.Notifications.NotificationSettingsService>();
|
||||||
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ISmtpDispatcher,
|
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ISmtpDispatcher,
|
||||||
ROLAC.API.Services.Notifications.MailKitSmtpDispatcher>();
|
ROLAC.API.Services.Notifications.MailKitSmtpDispatcher>();
|
||||||
builder.Services.AddScoped<ROLAC.API.Services.Notifications.IEmailService,
|
builder.Services.AddScoped<ROLAC.API.Services.Notifications.IEmailService,
|
||||||
|
|||||||
@@ -60,6 +60,22 @@ public class AuthService : IAuthService
|
|||||||
throw new UnauthorizedAccessException("Account is inactive.");
|
throw new UnauthorizedAccessException("Account is inactive.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_audit.Write(
|
||||||
|
AuditActions.Login, AuditCategories.Security, LogLevelEnum.Information,
|
||||||
|
entityName: nameof(AppUser), entityId: user.Id,
|
||||||
|
summary: $"Login succeeded: {user.Email}",
|
||||||
|
userId: user.Id, userEmail: user.Email, ipAddress: ipAddress);
|
||||||
|
|
||||||
|
return await IssueSessionAsync(user, ipAddress, deviceInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Issue session (shared by login and passwordless flows like invitations)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public async Task<(LoginResponse Response, string RawRefreshToken)> IssueSessionAsync(
|
||||||
|
AppUser user, string? ipAddress = null, string? deviceInfo = null)
|
||||||
|
{
|
||||||
var roles = await _userManager.GetRolesAsync(user);
|
var roles = await _userManager.GetRolesAsync(user);
|
||||||
var accessToken = _tokenService.GenerateAccessToken(user, roles);
|
var accessToken = _tokenService.GenerateAccessToken(user, roles);
|
||||||
var rawRefresh = _tokenService.GenerateRefreshToken();
|
var rawRefresh = _tokenService.GenerateRefreshToken();
|
||||||
@@ -79,12 +95,6 @@ public class AuthService : IAuthService
|
|||||||
await _userManager.UpdateAsync(user);
|
await _userManager.UpdateAsync(user);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
_audit.Write(
|
|
||||||
AuditActions.Login, AuditCategories.Security, LogLevelEnum.Information,
|
|
||||||
entityName: nameof(AppUser), entityId: user.Id,
|
|
||||||
summary: $"Login succeeded: {user.Email}",
|
|
||||||
userId: user.Id, userEmail: user.Email, ipAddress: ipAddress);
|
|
||||||
|
|
||||||
return (await BuildResponseAsync(accessToken, user, roles), rawRefresh);
|
return (await BuildResponseAsync(accessToken, user, roles), rawRefresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,5 +235,29 @@ public class AuthService : IAuthService
|
|||||||
Roles = roles,
|
Roles = roles,
|
||||||
LanguagePreference = user.LanguagePreference,
|
LanguagePreference = user.LanguagePreference,
|
||||||
Permissions = await _permissions.GetEffectivePermissionsAsync(roles),
|
Permissions = await _permissions.GetEffectivePermissionsAsync(roles),
|
||||||
|
MemberInfo = await BuildMemberInfoAsync(user),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the linked member's display fields, or null when the account has no
|
||||||
|
/// MemberId or its member record was soft-deleted (excluded by query filter).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<MemberInfo?> BuildMemberInfoAsync(AppUser user)
|
||||||
|
{
|
||||||
|
if (user.MemberId is not int memberId)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return await _db.Members
|
||||||
|
.Where(member => member.Id == memberId)
|
||||||
|
.Select(member => new MemberInfo
|
||||||
|
{
|
||||||
|
Id = member.Id,
|
||||||
|
NickName = member.NickName,
|
||||||
|
FirstName_en = member.FirstName_en,
|
||||||
|
LastName_en = member.LastName_en,
|
||||||
|
FirstName_zh = member.FirstName_zh,
|
||||||
|
LastName_zh = member.LastName_zh,
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ public class ChurchProfileService : IChurchProfileService
|
|||||||
var p = await GetOrCreateAsync();
|
var p = await GetOrCreateAsync();
|
||||||
return new ChurchProfileDto
|
return new ChurchProfileDto
|
||||||
{
|
{
|
||||||
Id = p.Id, Name = p.Name, Address = p.Address, City = p.City, State = p.State,
|
Id = p.Id, Name = p.Name, NameZh = p.NameZh, Phone = p.Phone, Email = p.Email,
|
||||||
|
Website = p.Website, Address = p.Address, City = p.City, State = p.State,
|
||||||
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
|
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
|
||||||
BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber,
|
BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber,
|
||||||
};
|
};
|
||||||
@@ -24,7 +25,8 @@ public class ChurchProfileService : IChurchProfileService
|
|||||||
public async Task UpdateAsync(UpdateChurchProfileRequest r)
|
public async Task UpdateAsync(UpdateChurchProfileRequest r)
|
||||||
{
|
{
|
||||||
var p = await GetOrCreateAsync();
|
var p = await GetOrCreateAsync();
|
||||||
p.Name = r.Name; p.Address = r.Address; p.City = r.City; p.State = r.State;
|
p.Name = r.Name; p.NameZh = r.NameZh; p.Phone = r.Phone; p.Email = r.Email;
|
||||||
|
p.Website = r.Website; p.Address = r.Address; p.City = r.City; p.State = r.State;
|
||||||
p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber;
|
p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber;
|
||||||
p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;
|
p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ public interface IAuthService
|
|||||||
string rawRefreshToken,
|
string rawRefreshToken,
|
||||||
string? ipAddress = null);
|
string? ipAddress = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issues a fresh access token + refresh token for an already-verified user (no password
|
||||||
|
/// check). Stores the refresh token and returns the raw value for the caller to put in the
|
||||||
|
/// HttpOnly cookie. Used by passwordless flows such as accepting an invitation link.
|
||||||
|
/// </summary>
|
||||||
|
Task<(LoginResponse Response, string RawRefreshToken)> IssueSessionAsync(
|
||||||
|
AppUser user,
|
||||||
|
string? ipAddress = null,
|
||||||
|
string? deviceInfo = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Revokes the refresh token identified by its raw value.
|
/// Revokes the refresh token identified by its raw value.
|
||||||
/// Silently succeeds if the token is not found.
|
/// Silently succeeds if the token is not found.
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using ROLAC.API.DTOs.Invitations;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public interface IInvitationService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a single-use, 7-day invitation link for a member. Auto-creates the member's
|
||||||
|
/// login account (no password) when none exists, and revokes any prior unused invitation for
|
||||||
|
/// that account. Returns the raw token (shown once) and its expiry.
|
||||||
|
/// Throws <see cref="InvalidOperationException"/> when the member is missing or has no email.
|
||||||
|
/// </summary>
|
||||||
|
Task<CreateInvitationResult> CreateAsync(CreateInvitationRequest request);
|
||||||
|
|
||||||
|
/// <summary>Checks whether a raw token is still usable, without mutating it.</summary>
|
||||||
|
Task<ValidateInvitationResult> ValidateAsync(string rawToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Consumes an invitation: validates the token, sets the account password (enforcing the
|
||||||
|
/// Identity policy), and marks the invitation used. Returns the account on success, or an
|
||||||
|
/// error message describing why it failed (invalid/expired/used token or a policy violation).
|
||||||
|
/// </summary>
|
||||||
|
Task<(AppUser? User, string? Error)> AcceptAsync(string rawToken, string newPassword);
|
||||||
|
|
||||||
|
/// <summary>E-mails an already-generated invitation link to the member via IEmailService.</summary>
|
||||||
|
Task SendEmailAsync(int memberId, string link);
|
||||||
|
}
|
||||||
@@ -22,6 +22,13 @@ public interface IMealAttendanceService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<AttendanceCountsDto> SetAsync(DateOnly date, string category, int value);
|
Task<AttendanceCountsDto> SetAsync(DateOnly date, string category, int value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overwrites all three age-group columns for <paramref name="date"/> with absolute
|
||||||
|
/// values (each clamped at zero), creating the row if it does not exist, and returns
|
||||||
|
/// the resulting authoritative counts. Used by the back-office Sunday-attendance editor.
|
||||||
|
/// </summary>
|
||||||
|
Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid);
|
||||||
|
|
||||||
/// <summary>Returns the daily counts within the inclusive date range, ordered by date (for the dashboard).</summary>
|
/// <summary>Returns the daily counts within the inclusive date range, ordered by date (for the dashboard).</summary>
|
||||||
Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to);
|
Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using ROLAC.API.DTOs.Settings;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads and writes the singleton SiteSetting and NotificationSetting rows. Notification secrets
|
||||||
|
/// are masked on read and treated as write-only on update (blank = keep). After a notification
|
||||||
|
/// update the runtime cache is reloaded so changes apply without an API restart.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISettingsService
|
||||||
|
{
|
||||||
|
Task<SiteSettingDto> GetSiteAsync();
|
||||||
|
Task UpdateSiteAsync(UpdateSiteSettingRequest request);
|
||||||
|
|
||||||
|
Task<NotificationSettingDto> GetNotificationAsync();
|
||||||
|
Task UpdateNotificationAsync(UpdateNotificationSettingRequest request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Invitations;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Entities.Logging;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public class InvitationService : IInvitationService
|
||||||
|
{
|
||||||
|
/// <summary>Lifetime of a freshly issued invitation link.</summary>
|
||||||
|
private const int InvitationLifetimeDays = 7;
|
||||||
|
|
||||||
|
private readonly UserManager<AppUser> _userManager;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITokenService _tokenService;
|
||||||
|
private readonly IEmailService _emailService;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly CurrentUserAccessor _currentUser;
|
||||||
|
|
||||||
|
public InvitationService(
|
||||||
|
UserManager<AppUser> userManager,
|
||||||
|
AppDbContext db,
|
||||||
|
ITokenService tokenService,
|
||||||
|
IEmailService emailService,
|
||||||
|
IAuditLogger audit,
|
||||||
|
CurrentUserAccessor currentUser)
|
||||||
|
{
|
||||||
|
_userManager = userManager;
|
||||||
|
_db = db;
|
||||||
|
_tokenService = tokenService;
|
||||||
|
_emailService = emailService;
|
||||||
|
_audit = audit;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<CreateInvitationResult> CreateAsync(CreateInvitationRequest request)
|
||||||
|
{
|
||||||
|
var member = await _db.Members.FindAsync(request.MemberId)
|
||||||
|
?? throw new InvalidOperationException($"Member {request.MemberId} does not exist.");
|
||||||
|
|
||||||
|
var email = (request.Email ?? member.Email)?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"This member has no email address. Add an email before creating an invitation.");
|
||||||
|
|
||||||
|
var user = await _userManager.Users.FirstOrDefaultAsync(u => u.MemberId == request.MemberId);
|
||||||
|
if (user is null)
|
||||||
|
user = await CreateAccountAsync(member, email, request.Roles);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Re-issue: revoke any prior unused invitation so only one link is ever live.
|
||||||
|
var existing = await _db.UserInvitations
|
||||||
|
.Where(invitation => invitation.UserId == user.Id
|
||||||
|
&& invitation.UsedAt == null
|
||||||
|
&& invitation.RevokedAt == null)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var invitation in existing)
|
||||||
|
invitation.RevokedAt = now;
|
||||||
|
|
||||||
|
var rawToken = GenerateRawToken();
|
||||||
|
var expiresAt = now.AddDays(InvitationLifetimeDays);
|
||||||
|
|
||||||
|
_db.UserInvitations.Add(new UserInvitation
|
||||||
|
{
|
||||||
|
UserId = user.Id,
|
||||||
|
TokenHash = _tokenService.HashToken(rawToken),
|
||||||
|
ExpiresAt = expiresAt,
|
||||||
|
CreatedAt = now,
|
||||||
|
CreatedBy = _currentUser.UserIdOrSystem,
|
||||||
|
});
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_audit.Write(
|
||||||
|
AuditActions.InvitationCreated, AuditCategories.Security, LogLevelEnum.Information,
|
||||||
|
entityName: nameof(AppUser), entityId: user.Id,
|
||||||
|
summary: $"Invitation link created for {user.Email}");
|
||||||
|
|
||||||
|
return new CreateInvitationResult { Token = rawToken, ExpiresAt = expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a passwordless login account linked to the member; mirrors UserManagementService.</summary>
|
||||||
|
private async Task<AppUser> CreateAccountAsync(Member member, string email, List<string>? roles)
|
||||||
|
{
|
||||||
|
if (await _userManager.FindByEmailAsync(email) is not null)
|
||||||
|
throw new InvalidOperationException($"Email '{email}' is already in use by another account.");
|
||||||
|
|
||||||
|
var user = new AppUser
|
||||||
|
{
|
||||||
|
UserName = email,
|
||||||
|
Email = email,
|
||||||
|
EmailConfirmed = true,
|
||||||
|
MemberId = member.Id,
|
||||||
|
LanguagePreference = member.LanguagePreference,
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
// No-password overload: the member sets their own password via the invitation link.
|
||||||
|
var result = await _userManager.CreateAsync(user);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
string.Join("; ", result.Errors.Select(error => error.Description)));
|
||||||
|
|
||||||
|
var rolesToAssign = roles is { Count: > 0 } ? roles : new List<string> { "member" };
|
||||||
|
await _userManager.AddToRolesAsync(user, rolesToAssign);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validate ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<ValidateInvitationResult> ValidateAsync(string rawToken)
|
||||||
|
{
|
||||||
|
var invitation = await FindByRawTokenAsync(rawToken);
|
||||||
|
if (invitation is null || invitation.IsUsed || invitation.IsRevoked)
|
||||||
|
return new ValidateInvitationResult { Valid = false, Expired = false };
|
||||||
|
if (invitation.IsExpired)
|
||||||
|
return new ValidateInvitationResult { Valid = false, Expired = true };
|
||||||
|
|
||||||
|
var user = await _userManager.FindByIdAsync(invitation.UserId);
|
||||||
|
return new ValidateInvitationResult
|
||||||
|
{
|
||||||
|
Valid = true,
|
||||||
|
Expired = false,
|
||||||
|
Email = user?.Email,
|
||||||
|
MemberName = await ResolveMemberNameAsync(user),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Accept ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<(AppUser? User, string? Error)> AcceptAsync(string rawToken, string newPassword)
|
||||||
|
{
|
||||||
|
var invitation = await FindByRawTokenAsync(rawToken);
|
||||||
|
if (invitation is null || invitation.IsUsed || invitation.IsRevoked)
|
||||||
|
return (null, "This invitation link is invalid or has already been used.");
|
||||||
|
if (invitation.IsExpired)
|
||||||
|
return (null, "This invitation link has expired. Please ask for a new one.");
|
||||||
|
|
||||||
|
var user = await _userManager.FindByIdAsync(invitation.UserId);
|
||||||
|
if (user is null)
|
||||||
|
return (null, "The account for this invitation no longer exists.");
|
||||||
|
|
||||||
|
// Set the password — works whether or not one already exists, and enforces the policy.
|
||||||
|
var resetToken = await _userManager.GeneratePasswordResetTokenAsync(user);
|
||||||
|
var result = await _userManager.ResetPasswordAsync(user, resetToken, newPassword);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
return (null, string.Join(" ", result.Errors.Select(error => error.Description)));
|
||||||
|
|
||||||
|
invitation.UsedAt = DateTime.UtcNow;
|
||||||
|
user.EmailConfirmed = true;
|
||||||
|
user.IsActive = true;
|
||||||
|
await _userManager.UpdateAsync(user);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_audit.Write(
|
||||||
|
AuditActions.InvitationAccepted, AuditCategories.Security, LogLevelEnum.Information,
|
||||||
|
entityName: nameof(AppUser), entityId: user.Id,
|
||||||
|
summary: $"Invitation accepted — password set for {user.Email}",
|
||||||
|
userId: user.Id, userEmail: user.Email);
|
||||||
|
|
||||||
|
return (user, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Send email ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task SendEmailAsync(int memberId, string link)
|
||||||
|
{
|
||||||
|
var member = await _db.Members.FindAsync(memberId)
|
||||||
|
?? throw new InvalidOperationException($"Member {memberId} does not exist.");
|
||||||
|
|
||||||
|
var name = WebUtility.HtmlEncode(member.NickName ?? member.FirstName_en);
|
||||||
|
var safeLink = WebUtility.HtmlEncode(link);
|
||||||
|
var subject = "Your River Of Life Christian Church account invitation";
|
||||||
|
var htmlBody =
|
||||||
|
$"<p>Hi {name},</p>" +
|
||||||
|
"<p>You've been invited to set up your account for the River Of Life Christian Church portal.</p>" +
|
||||||
|
$"<p>Click the link below to set your password and sign in. This link expires in {InvitationLifetimeDays} days and can only be used once.</p>" +
|
||||||
|
$"<p><a href=\"{safeLink}\">Set your password and sign in</a></p>" +
|
||||||
|
"<p>If the button doesn't work, copy and paste this address into your browser:</p>" +
|
||||||
|
$"<p>{safeLink}</p>";
|
||||||
|
|
||||||
|
var result = await _emailService.SendAsync(new EmailMessage(
|
||||||
|
MemberIds: new[] { memberId },
|
||||||
|
Addresses: Array.Empty<string>(),
|
||||||
|
Subject: subject,
|
||||||
|
HtmlBody: htmlBody));
|
||||||
|
|
||||||
|
if (result.SentCount == 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
result.Failures.Count > 0
|
||||||
|
? $"Failed to send email: {result.Failures[0].Error}"
|
||||||
|
: "No email address on file for this member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Task<UserInvitation?> FindByRawTokenAsync(string rawToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rawToken))
|
||||||
|
return Task.FromResult<UserInvitation?>(null);
|
||||||
|
|
||||||
|
var hash = _tokenService.HashToken(rawToken);
|
||||||
|
return _db.UserInvitations.FirstOrDefaultAsync(invitation => invitation.TokenHash == hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> ResolveMemberNameAsync(AppUser? user)
|
||||||
|
{
|
||||||
|
if (user?.MemberId is not int memberId)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return await _db.Members
|
||||||
|
.Where(member => member.Id == memberId)
|
||||||
|
.Select(member => (member.NickName ?? member.FirstName_en) + " " + member.LastName_en)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>32 cryptographically-random bytes as a URL-safe base64 string.</summary>
|
||||||
|
private static string GenerateRawToken()
|
||||||
|
{
|
||||||
|
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||||
|
return Convert.ToBase64String(bytes)
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_')
|
||||||
|
.TrimEnd('=');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,26 @@ public class MealAttendanceService : IMealAttendanceService
|
|||||||
return await ReadAsync(date);
|
return await ReadAsync(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid)
|
||||||
|
{
|
||||||
|
// Single-editor back-office path, so a tracked load + SaveChanges is fine here; no need for the
|
||||||
|
// race-safe EnsureRowAsync + ExecuteUpdateAsync pattern, which the EF InMemory test provider can't run.
|
||||||
|
var row = await _db.MealAttendances.FirstOrDefaultAsync(a => a.AttendanceDate == date);
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
row = new MealAttendance { AttendanceDate = date };
|
||||||
|
_db.MealAttendances.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts can never be negative; clamp before writing.
|
||||||
|
row.AdultCount = adult < 0 ? 0 : adult;
|
||||||
|
row.YouthCount = youth < 0 ? 0 : youth;
|
||||||
|
row.KidCount = kid < 0 ? 0 : kid;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return ToDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to)
|
public async Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to)
|
||||||
{
|
{
|
||||||
var rows = await _db.MealAttendances.AsNoTracking()
|
var rows = await _db.MealAttendances.AsNoTracking()
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ public class MemberService : IMemberService
|
|||||||
(m.NickName != null && m.NickName.ToLower().Contains(s)) ||
|
(m.NickName != null && m.NickName.ToLower().Contains(s)) ||
|
||||||
(m.FirstName_zh != null && m.FirstName_zh.Contains(search)) ||
|
(m.FirstName_zh != null && m.FirstName_zh.Contains(search)) ||
|
||||||
(m.LastName_zh != null && m.LastName_zh.Contains(search)) ||
|
(m.LastName_zh != null && m.LastName_zh.Contains(search)) ||
|
||||||
|
(m.Entity != null && m.Entity.ToLower().Contains(s)) ||
|
||||||
(m.Email != null && m.Email.ToLower().Contains(s)));
|
(m.Email != null && m.Email.ToLower().Contains(s)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ public class MemberService : IMemberService
|
|||||||
NickName = m.NickName,
|
NickName = m.NickName,
|
||||||
FirstName_zh = m.FirstName_zh,
|
FirstName_zh = m.FirstName_zh,
|
||||||
LastName_zh = m.LastName_zh,
|
LastName_zh = m.LastName_zh,
|
||||||
|
Entity = m.Entity,
|
||||||
Status = m.Status,
|
Status = m.Status,
|
||||||
Email = m.Email,
|
Email = m.Email,
|
||||||
PhoneCell = m.PhoneCell,
|
PhoneCell = m.PhoneCell,
|
||||||
@@ -105,6 +107,7 @@ public class MemberService : IMemberService
|
|||||||
{
|
{
|
||||||
Id = m.Id, FirstName_en = m.FirstName_en, LastName_en = m.LastName_en,
|
Id = m.Id, FirstName_en = m.FirstName_en, LastName_en = m.LastName_en,
|
||||||
NickName = m.NickName, FirstName_zh = m.FirstName_zh, LastName_zh = m.LastName_zh,
|
NickName = m.NickName, FirstName_zh = m.FirstName_zh, LastName_zh = m.LastName_zh,
|
||||||
|
Entity = m.Entity,
|
||||||
Gender = m.Gender, DateOfBirth = m.DateOfBirth, BaptismDate = m.BaptismDate,
|
Gender = m.Gender, DateOfBirth = m.DateOfBirth, BaptismDate = m.BaptismDate,
|
||||||
BaptismChurch = m.BaptismChurch, Email = m.Email, PhoneCell = m.PhoneCell,
|
BaptismChurch = m.BaptismChurch, Email = m.Email, PhoneCell = m.PhoneCell,
|
||||||
PhoneHome = m.PhoneHome, Address = m.Address, City = m.City, State = m.State,
|
PhoneHome = m.PhoneHome, Address = m.Address, City = m.City, State = m.State,
|
||||||
@@ -157,6 +160,7 @@ public class MemberService : IMemberService
|
|||||||
{
|
{
|
||||||
FirstName_en = r.FirstName_en, LastName_en = r.LastName_en,
|
FirstName_en = r.FirstName_en, LastName_en = r.LastName_en,
|
||||||
NickName = r.NickName, FirstName_zh = r.FirstName_zh, LastName_zh = r.LastName_zh,
|
NickName = r.NickName, FirstName_zh = r.FirstName_zh, LastName_zh = r.LastName_zh,
|
||||||
|
Entity = r.Entity,
|
||||||
Gender = r.Gender, DateOfBirth = r.DateOfBirth, BaptismDate = r.BaptismDate,
|
Gender = r.Gender, DateOfBirth = r.DateOfBirth, BaptismDate = r.BaptismDate,
|
||||||
BaptismChurch = r.BaptismChurch, Email = r.Email, PhoneCell = r.PhoneCell,
|
BaptismChurch = r.BaptismChurch, Email = r.Email, PhoneCell = r.PhoneCell,
|
||||||
PhoneHome = r.PhoneHome, Address = r.Address, City = r.City, State = r.State,
|
PhoneHome = r.PhoneHome, Address = r.Address, City = r.City, State = r.State,
|
||||||
@@ -169,6 +173,7 @@ public class MemberService : IMemberService
|
|||||||
{
|
{
|
||||||
m.FirstName_en = r.FirstName_en; m.LastName_en = r.LastName_en;
|
m.FirstName_en = r.FirstName_en; m.LastName_en = r.LastName_en;
|
||||||
m.NickName = r.NickName; m.FirstName_zh = r.FirstName_zh; m.LastName_zh = r.LastName_zh;
|
m.NickName = r.NickName; m.FirstName_zh = r.FirstName_zh; m.LastName_zh = r.LastName_zh;
|
||||||
|
m.Entity = r.Entity;
|
||||||
m.Gender = r.Gender; m.DateOfBirth = r.DateOfBirth; m.BaptismDate = r.BaptismDate;
|
m.Gender = r.Gender; m.DateOfBirth = r.DateOfBirth; m.BaptismDate = r.BaptismDate;
|
||||||
m.BaptismChurch = r.BaptismChurch; m.Email = r.Email; m.PhoneCell = r.PhoneCell;
|
m.BaptismChurch = r.BaptismChurch; m.Email = r.Email; m.PhoneCell = r.PhoneCell;
|
||||||
m.PhoneHome = r.PhoneHome; m.Address = r.Address; m.City = r.City; m.State = r.State;
|
m.PhoneHome = r.PhoneHome; m.Address = r.Address; m.City = r.City; m.State = r.State;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace ROLAC.API.Services.Notifications;
|
namespace ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
@@ -11,12 +10,12 @@ public sealed class LineMessageChannel : IMessageChannel
|
|||||||
private const string ReplyUrl = "https://api.line.me/v2/bot/message/reply";
|
private const string ReplyUrl = "https://api.line.me/v2/bot/message/reply";
|
||||||
|
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly LineOptions _options;
|
private readonly INotificationSettingsService _settings;
|
||||||
|
|
||||||
public LineMessageChannel(HttpClient http, IOptions<LineOptions> options)
|
public LineMessageChannel(HttpClient http, INotificationSettingsService settings)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_options = options.Value;
|
_settings = settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
|
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
|
||||||
@@ -36,7 +35,8 @@ public sealed class LineMessageChannel : IMessageChannel
|
|||||||
{
|
{
|
||||||
Content = JsonContent.Create(payload),
|
Content = JsonContent.Create(payload),
|
||||||
};
|
};
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ChannelAccessToken);
|
request.Headers.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", _settings.GetLine().ChannelAccessToken);
|
||||||
|
|
||||||
using var response = await _http.SendAsync(request, ct);
|
using var response = await _http.SendAsync(request, ct);
|
||||||
if (response.IsSuccessStatusCode) return new MessageSendResult(true, null);
|
if (response.IsSuccessStatusCode) return new MessageSendResult(true, null);
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
using MailKit.Net.Smtp;
|
using MailKit.Net.Smtp;
|
||||||
using MailKit.Security;
|
using MailKit.Security;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using MimeKit;
|
using MimeKit;
|
||||||
|
|
||||||
namespace ROLAC.API.Services.Notifications;
|
namespace ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
/// <summary>Sends a single email via MailKit using the configured SMTP server.</summary>
|
/// <summary>Sends a single email via MailKit using the current (DB-backed) SMTP settings.</summary>
|
||||||
public sealed class MailKitSmtpDispatcher : ISmtpDispatcher
|
public sealed class MailKitSmtpDispatcher : ISmtpDispatcher
|
||||||
{
|
{
|
||||||
private readonly SmtpOptions _options;
|
private readonly INotificationSettingsService _settings;
|
||||||
|
|
||||||
public MailKitSmtpDispatcher(IOptions<SmtpOptions> options) => _options = options.Value;
|
public MailKitSmtpDispatcher(INotificationSettingsService settings) => _settings = settings;
|
||||||
|
|
||||||
public async Task SendAsync(OutboundEmail email, CancellationToken ct = default)
|
public async Task SendAsync(OutboundEmail email, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
var options = _settings.GetSmtp();
|
||||||
|
|
||||||
var message = new MimeMessage();
|
var message = new MimeMessage();
|
||||||
message.From.Add(new MailboxAddress(_options.FromName, _options.FromAddress));
|
message.From.Add(new MailboxAddress(options.FromName, options.FromAddress));
|
||||||
message.To.Add(MailboxAddress.Parse(email.ToAddress));
|
message.To.Add(MailboxAddress.Parse(email.ToAddress));
|
||||||
message.Subject = email.Subject;
|
message.Subject = email.Subject;
|
||||||
|
|
||||||
@@ -28,10 +29,10 @@ public sealed class MailKitSmtpDispatcher : ISmtpDispatcher
|
|||||||
message.Body = builder.ToMessageBody();
|
message.Body = builder.ToMessageBody();
|
||||||
|
|
||||||
using var client = new SmtpClient();
|
using var client = new SmtpClient();
|
||||||
var socketOptions = _options.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
var socketOptions = options.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
||||||
await client.ConnectAsync(_options.Host, _options.Port, socketOptions, ct);
|
await client.ConnectAsync(options.Host, options.Port, socketOptions, ct);
|
||||||
if (!string.IsNullOrEmpty(_options.User))
|
if (!string.IsNullOrEmpty(options.User))
|
||||||
await client.AuthenticateAsync(_options.User, _options.Password, ct);
|
await client.AuthenticateAsync(options.User, options.Password, ct);
|
||||||
await client.SendAsync(message, ct);
|
await client.SendAsync(message, ct);
|
||||||
await client.DisconnectAsync(true, ct);
|
await client.DisconnectAsync(true, ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Supplies the current SMTP/Line settings from the <c>NotificationSetting</c> singleton row,
|
||||||
|
/// caching a snapshot in memory so send paths don't hit the DB on every message. Registered as a
|
||||||
|
/// singleton; the Settings UI calls <see cref="Reload"/> after an edit so changes take effect
|
||||||
|
/// without restarting the API. Falls back to the "Smtp"/"Line" appsettings sections if the row
|
||||||
|
/// has not been seeded yet.
|
||||||
|
/// </summary>
|
||||||
|
public interface INotificationSettingsService
|
||||||
|
{
|
||||||
|
SmtpOptions GetSmtp();
|
||||||
|
LineOptions GetLine();
|
||||||
|
void Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NotificationSettingsService : INotificationSettingsService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly IOptions<SmtpOptions> _smtpFallback;
|
||||||
|
private readonly IOptions<LineOptions> _lineFallback;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
|
||||||
|
private SmtpOptions? _smtp;
|
||||||
|
private LineOptions? _line;
|
||||||
|
|
||||||
|
public NotificationSettingsService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IOptions<SmtpOptions> smtpFallback,
|
||||||
|
IOptions<LineOptions> lineFallback)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_smtpFallback = smtpFallback;
|
||||||
|
_lineFallback = lineFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SmtpOptions GetSmtp()
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
return _smtp!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LineOptions GetLine()
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
return _line!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reload()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_smtp = null;
|
||||||
|
_line = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureLoaded()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_smtp is not null && _line is not null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var row = db.NotificationSettings.AsNoTracking().OrderBy(s => s.Id).FirstOrDefault();
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
// Not seeded yet — use the appsettings values so sends still work.
|
||||||
|
_smtp = _smtpFallback.Value;
|
||||||
|
_line = _lineFallback.Value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_smtp = new SmtpOptions
|
||||||
|
{
|
||||||
|
Host = row.SmtpHost,
|
||||||
|
Port = row.SmtpPort,
|
||||||
|
UseSsl = row.SmtpUseSsl,
|
||||||
|
User = row.SmtpUser,
|
||||||
|
Password = row.SmtpPassword,
|
||||||
|
FromAddress = row.FromAddress,
|
||||||
|
FromName = row.FromName,
|
||||||
|
};
|
||||||
|
_line = new LineOptions
|
||||||
|
{
|
||||||
|
ChannelAccessToken = row.LineChannelAccessToken,
|
||||||
|
ChannelSecret = row.LineChannelSecret,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,11 @@ public class OfferingSessionService : IOfferingSessionService
|
|||||||
.Select(grp => new { Id = grp.Key, Count = grp.Count() })
|
.Select(grp => new { Id = grp.Key, Count = grp.Count() })
|
||||||
.ToDictionaryAsync(x => x.Id, x => x.Count);
|
.ToDictionaryAsync(x => x.Id, x => x.Count);
|
||||||
|
|
||||||
|
var dates = rows.Select(r => r.SessionDate).ToList();
|
||||||
|
var attendance = await _db.MealAttendances.AsNoTracking()
|
||||||
|
.Where(a => dates.Contains(a.AttendanceDate))
|
||||||
|
.ToDictionaryAsync(a => a.AttendanceDate, a => a.AdultCount + a.YouthCount + a.KidCount);
|
||||||
|
|
||||||
var items = rows.Select(s => new OfferingSessionListItemDto
|
var items = rows.Select(s => new OfferingSessionListItemDto
|
||||||
{
|
{
|
||||||
Id = s.Id, SessionDate = s.SessionDate.ToString("yyyy-MM-dd"), Status = s.Status,
|
Id = s.Id, SessionDate = s.SessionDate.ToString("yyyy-MM-dd"), Status = s.Status,
|
||||||
@@ -52,6 +57,7 @@ public class OfferingSessionService : IOfferingSessionService
|
|||||||
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,
|
HasProof = s.ProofPdfPath != null,
|
||||||
|
SundayAttendanceCount = attendance.TryGetValue(s.SessionDate, out var att) ? att : (int?)null,
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return new PagedResult<OfferingSessionListItemDto>
|
return new PagedResult<OfferingSessionListItemDto>
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Settings;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public class SettingsService : ISettingsService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly INotificationSettingsService _notificationSettings;
|
||||||
|
|
||||||
|
public SettingsService(AppDbContext db, INotificationSettingsService notificationSettings)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_notificationSettings = notificationSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SiteSettingDto> GetSiteAsync()
|
||||||
|
{
|
||||||
|
var s = await GetOrCreateSiteAsync();
|
||||||
|
return new SiteSettingDto
|
||||||
|
{
|
||||||
|
SiteTitle = s.SiteTitle,
|
||||||
|
SiteTitleZh = s.SiteTitleZh,
|
||||||
|
DefaultLanguage = s.DefaultLanguage,
|
||||||
|
TimeZone = s.TimeZone,
|
||||||
|
DateFormat = s.DateFormat,
|
||||||
|
Currency = s.Currency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateSiteAsync(UpdateSiteSettingRequest r)
|
||||||
|
{
|
||||||
|
var s = await GetOrCreateSiteAsync();
|
||||||
|
s.SiteTitle = r.SiteTitle;
|
||||||
|
s.SiteTitleZh = r.SiteTitleZh;
|
||||||
|
s.DefaultLanguage = r.DefaultLanguage;
|
||||||
|
s.TimeZone = r.TimeZone;
|
||||||
|
s.DateFormat = r.DateFormat;
|
||||||
|
s.Currency = r.Currency;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NotificationSettingDto> GetNotificationAsync()
|
||||||
|
{
|
||||||
|
var n = await GetOrCreateNotificationAsync();
|
||||||
|
return new NotificationSettingDto
|
||||||
|
{
|
||||||
|
EnableEmail = n.EnableEmail,
|
||||||
|
SmtpHost = n.SmtpHost,
|
||||||
|
SmtpPort = n.SmtpPort,
|
||||||
|
SmtpUseSsl = n.SmtpUseSsl,
|
||||||
|
SmtpUser = n.SmtpUser,
|
||||||
|
FromAddress = n.FromAddress,
|
||||||
|
FromName = n.FromName,
|
||||||
|
HasSmtpPassword = !string.IsNullOrEmpty(n.SmtpPassword),
|
||||||
|
EnableLine = n.EnableLine,
|
||||||
|
HasLineChannelAccessToken = !string.IsNullOrEmpty(n.LineChannelAccessToken),
|
||||||
|
HasLineChannelSecret = !string.IsNullOrEmpty(n.LineChannelSecret),
|
||||||
|
// WebhookUrl is filled by the controller (needs the request host).
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateNotificationAsync(UpdateNotificationSettingRequest r)
|
||||||
|
{
|
||||||
|
var n = await GetOrCreateNotificationAsync();
|
||||||
|
n.EnableEmail = r.EnableEmail;
|
||||||
|
n.SmtpHost = r.SmtpHost;
|
||||||
|
n.SmtpPort = r.SmtpPort;
|
||||||
|
n.SmtpUseSsl = r.SmtpUseSsl;
|
||||||
|
n.SmtpUser = r.SmtpUser;
|
||||||
|
n.FromAddress = r.FromAddress ?? "";
|
||||||
|
n.FromName = r.FromName ?? "";
|
||||||
|
n.EnableLine = r.EnableLine;
|
||||||
|
|
||||||
|
// Secrets are write-only: a blank value means "keep what's stored".
|
||||||
|
if (!string.IsNullOrWhiteSpace(r.SmtpPassword))
|
||||||
|
n.SmtpPassword = r.SmtpPassword;
|
||||||
|
if (!string.IsNullOrWhiteSpace(r.LineChannelAccessToken))
|
||||||
|
n.LineChannelAccessToken = r.LineChannelAccessToken;
|
||||||
|
if (!string.IsNullOrWhiteSpace(r.LineChannelSecret))
|
||||||
|
n.LineChannelSecret = r.LineChannelSecret;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Drop the cached snapshot so the new values are used on the next send — no restart needed.
|
||||||
|
_notificationSettings.Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SiteSetting> GetOrCreateSiteAsync()
|
||||||
|
{
|
||||||
|
var s = await _db.SiteSettings.OrderBy(x => x.Id).FirstOrDefaultAsync();
|
||||||
|
if (s is null)
|
||||||
|
{
|
||||||
|
s = new SiteSetting { SiteTitle = "Church" };
|
||||||
|
_db.SiteSettings.Add(s);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<NotificationSetting> GetOrCreateNotificationAsync()
|
||||||
|
{
|
||||||
|
var n = await _db.NotificationSettings.OrderBy(x => x.Id).FirstOrDefaultAsync();
|
||||||
|
if (n is null)
|
||||||
|
{
|
||||||
|
n = new NotificationSetting();
|
||||||
|
_db.NotificationSettings.Add(n);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,11 +24,15 @@ import { OfferingEntryMobilePageComponent } from './features/giving/pages/offeri
|
|||||||
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
|
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
|
||||||
import { AuditLogsPageComponent } from './features/logging/pages/audit-logs-page/audit-logs-page.component';
|
import { AuditLogsPageComponent } from './features/logging/pages/audit-logs-page/audit-logs-page.component';
|
||||||
import { AccountSettingsPageComponent } from './features/account/pages/account-settings-page/account-settings-page.component';
|
import { AccountSettingsPageComponent } from './features/account/pages/account-settings-page/account-settings-page.component';
|
||||||
|
import { AcceptInvitationComponent } from './features/accept-invitation/accept-invitation.component';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
// Public routes
|
// Public routes
|
||||||
{ path: 'login', component: LoginPage },
|
{ path: 'login', component: LoginPage },
|
||||||
|
|
||||||
|
// Public first-login page — member sets their own password from a secret invitation link.
|
||||||
|
{ path: 'accept-invitation', component: AcceptInvitationComponent },
|
||||||
|
|
||||||
// Public Sunday meal attendance counter — no login required (volunteers on phones).
|
// Public Sunday meal attendance counter — no login required (volunteers on phones).
|
||||||
{ path: 'attendance', component: AttendanceCounterPageComponent },
|
{ path: 'attendance', component: AttendanceCounterPageComponent },
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const PermissionModules = {
|
|||||||
Permissions: 'Permissions',
|
Permissions: 'Permissions',
|
||||||
SystemLogs: 'SystemLogs',
|
SystemLogs: 'SystemLogs',
|
||||||
AuditLogs: 'AuditLogs',
|
AuditLogs: 'AuditLogs',
|
||||||
|
Settings: 'Settings',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** A required permission, used in route data and the *appHasPermission directive. */
|
/** A required permission, used in route data and the *appHasPermission directive. */
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
|
import { LabelModule } from '@progress/kendo-angular-label';
|
||||||
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
|
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
|
||||||
|
import { AuthService } from '../../shared/services/auth.service';
|
||||||
|
import {
|
||||||
|
passwordStrengthValidator,
|
||||||
|
passwordMatchValidator,
|
||||||
|
} from '../account/validators/password.validators';
|
||||||
|
|
||||||
|
type Step = 'loading' | 'invalid' | 'form';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-accept-invitation',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule, ReactiveFormsModule,
|
||||||
|
InputsModule, LabelModule, ButtonsModule, IndicatorsModule,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div class="w-full max-w-md rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
|
|
||||||
|
<h1 class="text-xl font-semibold mb-1">River Of Life Christian Church</h1>
|
||||||
|
|
||||||
|
<!-- Validating the link -->
|
||||||
|
<ng-container *ngIf="step === 'loading'">
|
||||||
|
<div class="text-center py-6">
|
||||||
|
<kendo-loader></kendo-loader>
|
||||||
|
<p class="mt-2 text-gray-600">Checking your invitation…</p>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Invalid / expired link -->
|
||||||
|
<ng-container *ngIf="step === 'invalid'">
|
||||||
|
<p class="text-base font-medium mb-2">This invitation can't be used</p>
|
||||||
|
<p class="text-gray-600 mb-4">{{ invalidMessage }}</p>
|
||||||
|
<button kendoButton themeColor="primary" (click)="goToLogin()">Go to sign in</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Set password form -->
|
||||||
|
<ng-container *ngIf="step === 'form'">
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
Welcome<span *ngIf="memberName">, <strong>{{ memberName }}</strong></span>. Set a password to
|
||||||
|
finish creating your account and sign in.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form [formGroup]="form" class="k-form k-form-vertical" (ngSubmit)="onSubmit()">
|
||||||
|
<div class="grid grid-cols-1 gap-y-3">
|
||||||
|
|
||||||
|
<kendo-formfield>
|
||||||
|
<kendo-label text="New Password *"></kendo-label>
|
||||||
|
<kendo-textbox formControlName="newPassword" type="password" [clearButton]="false"></kendo-textbox>
|
||||||
|
<kendo-formerror *ngIf="form.get('newPassword')?.errors?.['required']">Required.</kendo-formerror>
|
||||||
|
<kendo-formerror *ngIf="form.get('newPassword')?.errors?.['passwordStrength']">
|
||||||
|
Must be at least 8 characters with an uppercase letter, a lowercase letter,
|
||||||
|
a digit, and a special character.
|
||||||
|
</kendo-formerror>
|
||||||
|
</kendo-formfield>
|
||||||
|
|
||||||
|
<kendo-formfield>
|
||||||
|
<kendo-label text="Confirm Password *"></kendo-label>
|
||||||
|
<kendo-textbox formControlName="confirmPassword" type="password" [clearButton]="false"></kendo-textbox>
|
||||||
|
<kendo-formerror *ngIf="form.get('confirmPassword')?.errors?.['required']">Required.</kendo-formerror>
|
||||||
|
<kendo-formerror *ngIf="form.errors?.['mismatch'] && form.get('confirmPassword')?.touched">
|
||||||
|
Passwords do not match.
|
||||||
|
</kendo-formerror>
|
||||||
|
</kendo-formfield>
|
||||||
|
|
||||||
|
<p *ngIf="errorMessage" class="k-color-error">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<button kendoButton themeColor="primary" type="submit" [disabled]="form.invalid || submitting">
|
||||||
|
<span *ngIf="submitting">…</span>
|
||||||
|
Set password & sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class AcceptInvitationComponent implements OnInit {
|
||||||
|
step: Step = 'loading';
|
||||||
|
form: FormGroup;
|
||||||
|
submitting = false;
|
||||||
|
memberName: string | null = null;
|
||||||
|
invalidMessage = 'This invitation link is invalid or has already been used.';
|
||||||
|
errorMessage = '';
|
||||||
|
|
||||||
|
private token = '';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private auth: AuthService,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
) {
|
||||||
|
this.form = this.fb.group(
|
||||||
|
{
|
||||||
|
newPassword: ['', [Validators.required, passwordStrengthValidator()]],
|
||||||
|
confirmPassword: ['', [Validators.required]],
|
||||||
|
},
|
||||||
|
{ validators: passwordMatchValidator() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.token = this.route.snapshot.queryParamMap.get('token') ?? '';
|
||||||
|
if (!this.token) {
|
||||||
|
this.step = 'invalid';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.auth.validateInvitation(this.token).subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
if (result.valid) {
|
||||||
|
this.memberName = result.memberName ?? null;
|
||||||
|
this.step = 'form';
|
||||||
|
} else {
|
||||||
|
this.invalidMessage = result.expired
|
||||||
|
? 'This invitation link has expired. Please ask for a new one.'
|
||||||
|
: 'This invitation link is invalid or has already been used.';
|
||||||
|
this.step = 'invalid';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => { this.step = 'invalid'; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(): void {
|
||||||
|
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
|
||||||
|
this.submitting = true;
|
||||||
|
this.errorMessage = '';
|
||||||
|
|
||||||
|
this.auth.acceptInvitation(this.token, this.form.value.newPassword).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.router.navigate(['/user-portal/dashboard']);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.errorMessage = err.error?.message ?? 'Could not set your password. The link may have expired.';
|
||||||
|
this.submitting = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goToLogin(): void {
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,8 @@ export interface CheckDetailDto extends CheckListItemDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChurchProfileDto {
|
export interface ChurchProfileDto {
|
||||||
id: number; name: string; address: string | null; city: string | null;
|
id: number; name: string; nameZh: string | null; phone: string | null;
|
||||||
|
email: string | null; website: string | null; address: string | null; city: string | null;
|
||||||
state: string | null; zipCode: string | null; bankName: string | null;
|
state: string | null; zipCode: string | null; bankName: string | null;
|
||||||
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
|
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-46
@@ -1,49 +1,86 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<div *ngIf="model" class="max-w-3xl">
|
<kendo-tabstrip>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
<!-- ── Tab 1: Church Info (existing ChurchProfile permission) ──────────── -->
|
||||||
<label class="flex flex-col gap-1 md:col-span-2">
|
<kendo-tabstrip-tab title="Church Info / 教會資料" [selected]="true">
|
||||||
Church Name / 教會名稱
|
<ng-template kendoTabContent>
|
||||||
<kendo-textbox [(ngModel)]="model.name"></kendo-textbox>
|
<div *ngIf="model" class="max-w-3xl pt-4">
|
||||||
</label>
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
<label class="flex flex-col gap-1 md:col-span-2">
|
<label class="flex flex-col gap-1">
|
||||||
Address / 地址
|
Church Name / 教會名稱
|
||||||
<kendo-textbox [(ngModel)]="model.address"></kendo-textbox>
|
<kendo-textbox [(ngModel)]="model.name"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
City / 城市
|
Church Name (ZH) / 教會名稱(中)
|
||||||
<kendo-textbox [(ngModel)]="model.city"></kendo-textbox>
|
<kendo-textbox [(ngModel)]="model.nameZh"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<label class="flex flex-col gap-1">
|
||||||
<label class="flex flex-col gap-1">
|
Phone / 電話
|
||||||
State / 州
|
<kendo-textbox [(ngModel)]="model.phone"></kendo-textbox>
|
||||||
<kendo-textbox [(ngModel)]="model.state"></kendo-textbox>
|
</label>
|
||||||
</label>
|
<label class="flex flex-col gap-1">
|
||||||
<label class="flex flex-col gap-1">
|
Email / 電子郵件
|
||||||
Zip / 郵遞區號
|
<kendo-textbox [(ngModel)]="model.email"></kendo-textbox>
|
||||||
<kendo-textbox [(ngModel)]="model.zipCode"></kendo-textbox>
|
</label>
|
||||||
</label>
|
<label class="flex flex-col gap-1 md:col-span-2">
|
||||||
</div>
|
Website / 網站
|
||||||
<label class="flex flex-col gap-1">
|
<kendo-textbox [(ngModel)]="model.website" placeholder="https://"></kendo-textbox>
|
||||||
Bank Name / 銀行名稱
|
</label>
|
||||||
<kendo-textbox [(ngModel)]="model.bankName"></kendo-textbox>
|
<label class="flex flex-col gap-1 md:col-span-2">
|
||||||
</label>
|
Address / 地址
|
||||||
<label class="flex flex-col gap-1">
|
<kendo-textbox [(ngModel)]="model.address"></kendo-textbox>
|
||||||
Bank Account # / 銀行帳號
|
</label>
|
||||||
<kendo-textbox [(ngModel)]="model.bankAccountNumber"></kendo-textbox>
|
<label class="flex flex-col gap-1">
|
||||||
</label>
|
City / 城市
|
||||||
<label class="flex flex-col gap-1">
|
<kendo-textbox [(ngModel)]="model.city"></kendo-textbox>
|
||||||
Routing # / 路由號碼
|
</label>
|
||||||
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox>
|
<div class="grid grid-cols-2 gap-2">
|
||||||
</label>
|
<label class="flex flex-col gap-1">
|
||||||
<label class="flex flex-col gap-1">
|
State / 州
|
||||||
Next Check # / 下一張支票號碼
|
<kendo-textbox [(ngModel)]="model.state"></kendo-textbox>
|
||||||
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox>
|
</label>
|
||||||
</label>
|
<label class="flex flex-col gap-1">
|
||||||
</div>
|
Zip / 郵遞區號
|
||||||
|
<kendo-textbox [(ngModel)]="model.zipCode"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Bank Name / 銀行名稱
|
||||||
|
<kendo-textbox [(ngModel)]="model.bankName"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Bank Account # / 銀行帳號
|
||||||
|
<kendo-textbox [(ngModel)]="model.bankAccountNumber"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Routing # / 路由號碼
|
||||||
|
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Next Check # / 下一張支票號碼
|
||||||
|
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 mt-4">
|
<div class="flex items-center gap-3 mt-4">
|
||||||
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
|
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
|
||||||
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
|
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-tabstrip-tab>
|
||||||
|
|
||||||
|
<!-- ── Tab 2: Site Settings (Settings permission) ─────────────────────── -->
|
||||||
|
<kendo-tabstrip-tab title="Site Settings / 網站設定" *appHasPermission="settingsPermission">
|
||||||
|
<ng-template kendoTabContent>
|
||||||
|
<app-site-settings-tab></app-site-settings-tab>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-tabstrip-tab>
|
||||||
|
|
||||||
|
<!-- ── Tab 3: Notification Settings (Settings permission) ─────────────── -->
|
||||||
|
<kendo-tabstrip-tab title="Notifications / 通知設定" *appHasPermission="settingsPermission">
|
||||||
|
<ng-template kendoTabContent>
|
||||||
|
<app-notification-settings-tab></app-notification-settings-tab>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-tabstrip-tab>
|
||||||
|
</kendo-tabstrip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+12
-1
@@ -3,13 +3,21 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
|
import { LayoutModule } from '@progress/kendo-angular-layout';
|
||||||
import { DisbursementApiService } from '../../services/disbursement-api.service';
|
import { DisbursementApiService } from '../../services/disbursement-api.service';
|
||||||
import { ChurchProfileDto } from '../../models/disbursement.model';
|
import { ChurchProfileDto } from '../../models/disbursement.model';
|
||||||
|
import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
|
||||||
|
import { PermissionModules } from '../../../../core/models/permission.model';
|
||||||
|
import { SiteSettingsTabComponent } from '../../../settings/components/site-settings-tab/site-settings-tab.component';
|
||||||
|
import { NotificationSettingsTabComponent } from '../../../settings/components/notification-settings-tab/notification-settings-tab.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-church-profile-page',
|
selector: 'app-church-profile-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule],
|
imports: [
|
||||||
|
CommonModule, FormsModule, ButtonsModule, InputsModule, LayoutModule,
|
||||||
|
HasPermissionDirective, SiteSettingsTabComponent, NotificationSettingsTabComponent,
|
||||||
|
],
|
||||||
templateUrl: './church-profile-page.component.html',
|
templateUrl: './church-profile-page.component.html',
|
||||||
})
|
})
|
||||||
export class ChurchProfilePageComponent implements OnInit {
|
export class ChurchProfilePageComponent implements OnInit {
|
||||||
@@ -17,6 +25,9 @@ export class ChurchProfilePageComponent implements OnInit {
|
|||||||
saving = false;
|
saving = false;
|
||||||
savedMsg = '';
|
savedMsg = '';
|
||||||
|
|
||||||
|
/** Settings module gates the Site / Notification tabs. */
|
||||||
|
readonly settingsPermission = { module: PermissionModules.Settings, action: 'read' as const };
|
||||||
|
|
||||||
constructor(private api: DisbursementApiService) {}
|
constructor(private api: DisbursementApiService) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
|||||||
+2
@@ -2,8 +2,10 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
<label class="flex flex-col gap-1">First name (EN) *<kendo-textbox [(ngModel)]="firstName_en"></kendo-textbox></label>
|
<label class="flex flex-col gap-1">First name (EN) *<kendo-textbox [(ngModel)]="firstName_en"></kendo-textbox></label>
|
||||||
<label class="flex flex-col gap-1">Last name (EN) *<kendo-textbox [(ngModel)]="lastName_en"></kendo-textbox></label>
|
<label class="flex flex-col gap-1">Last name (EN) *<kendo-textbox [(ngModel)]="lastName_en"></kendo-textbox></label>
|
||||||
|
<label class="flex flex-col gap-1 md:col-span-2">暱稱 · Nick name<kendo-textbox [(ngModel)]="nickName"></kendo-textbox></label>
|
||||||
<label class="flex flex-col gap-1">名 (中)<kendo-textbox [(ngModel)]="firstName_zh"></kendo-textbox></label>
|
<label class="flex flex-col gap-1">名 (中)<kendo-textbox [(ngModel)]="firstName_zh"></kendo-textbox></label>
|
||||||
<label class="flex flex-col gap-1">姓 (中)<kendo-textbox [(ngModel)]="lastName_zh"></kendo-textbox></label>
|
<label class="flex flex-col gap-1">姓 (中)<kendo-textbox [(ngModel)]="lastName_zh"></kendo-textbox></label>
|
||||||
|
<label class="flex flex-col gap-1 md:col-span-2">公司行號 · Company<kendo-textbox [(ngModel)]="entity"></kendo-textbox></label>
|
||||||
<label class="flex flex-col gap-1 md:col-span-2">Cell phone<kendo-textbox [(ngModel)]="phoneCell"></kendo-textbox></label>
|
<label class="flex flex-col gap-1 md:col-span-2">Cell phone<kendo-textbox [(ngModel)]="phoneCell"></kendo-textbox></label>
|
||||||
</div>
|
</div>
|
||||||
<kendo-dialog-actions>
|
<kendo-dialog-actions>
|
||||||
|
|||||||
+6
-2
@@ -19,8 +19,10 @@ export class MemberQuickAddDialogComponent {
|
|||||||
|
|
||||||
firstName_en = '';
|
firstName_en = '';
|
||||||
lastName_en = '';
|
lastName_en = '';
|
||||||
|
nickName: string | null = null;
|
||||||
firstName_zh: string | null = null;
|
firstName_zh: string | null = null;
|
||||||
lastName_zh: string | null = null;
|
lastName_zh: string | null = null;
|
||||||
|
entity: string | null = null;
|
||||||
phoneCell: string | null = null;
|
phoneCell: string | null = null;
|
||||||
saving = false;
|
saving = false;
|
||||||
|
|
||||||
@@ -32,9 +34,10 @@ export class MemberQuickAddDialogComponent {
|
|||||||
const req: CreateMemberRequest = {
|
const req: CreateMemberRequest = {
|
||||||
firstName_en: this.firstName_en,
|
firstName_en: this.firstName_en,
|
||||||
lastName_en: this.lastName_en,
|
lastName_en: this.lastName_en,
|
||||||
nickName: null,
|
nickName: this.nickName,
|
||||||
firstName_zh: this.firstName_zh,
|
firstName_zh: this.firstName_zh,
|
||||||
lastName_zh: this.lastName_zh,
|
lastName_zh: this.lastName_zh,
|
||||||
|
entity: this.entity,
|
||||||
gender: null,
|
gender: null,
|
||||||
dateOfBirth: null,
|
dateOfBirth: null,
|
||||||
baptismDate: null,
|
baptismDate: null,
|
||||||
@@ -60,9 +63,10 @@ export class MemberQuickAddDialogComponent {
|
|||||||
id,
|
id,
|
||||||
firstName_en: this.firstName_en,
|
firstName_en: this.firstName_en,
|
||||||
lastName_en: this.lastName_en,
|
lastName_en: this.lastName_en,
|
||||||
nickName: null,
|
nickName: this.nickName,
|
||||||
firstName_zh: this.firstName_zh,
|
firstName_zh: this.firstName_zh,
|
||||||
lastName_zh: this.lastName_zh,
|
lastName_zh: this.lastName_zh,
|
||||||
|
entity: this.entity,
|
||||||
status: 'Visitor',
|
status: 'Visitor',
|
||||||
email: null,
|
email: null,
|
||||||
phoneCell: this.phoneCell,
|
phoneCell: this.phoneCell,
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export interface OfferingSessionListItemDto {
|
|||||||
difference: number;
|
difference: number;
|
||||||
lineCount: number;
|
lineCount: number;
|
||||||
hasProof: boolean;
|
hasProof: boolean;
|
||||||
|
sundayAttendanceCount?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A row held in the client-side batch buffer before submit. */
|
/** A row held in the client-side batch buffer before submit. */
|
||||||
@@ -129,6 +130,7 @@ export interface MemberTypeaheadDto {
|
|||||||
nickName: string | null;
|
nickName: string | null;
|
||||||
firstName_en: string;
|
firstName_en: string;
|
||||||
lastName_en: string;
|
lastName_en: string;
|
||||||
|
entity: string | null;
|
||||||
}
|
}
|
||||||
/** A day's session as the mobile page sees it. */
|
/** A day's session as the mobile page sees it. */
|
||||||
export interface OfferingEntrySummaryDto {
|
export interface OfferingEntrySummaryDto {
|
||||||
@@ -158,6 +160,7 @@ export interface QuickAddMemberRequest {
|
|||||||
nickName: string | null;
|
nickName: string | null;
|
||||||
firstName_zh: string | null;
|
firstName_zh: string | null;
|
||||||
lastName_zh: string | null;
|
lastName_zh: string | null;
|
||||||
|
entity: string | null;
|
||||||
phoneCell: string | null;
|
phoneCell: string | null;
|
||||||
}
|
}
|
||||||
/** Returned from append + broadcast over the OfferingEntryHub. */
|
/** Returned from append + broadcast over the OfferingEntryHub. */
|
||||||
|
|||||||
+4
@@ -130,6 +130,10 @@
|
|||||||
<label class="oe__label">中文姓 · Chinese last name</label>
|
<label class="oe__label">中文姓 · Chinese last name</label>
|
||||||
<kendo-textbox class="oe__control" [(ngModel)]="quickAdd.lastName_zh" size="large"></kendo-textbox>
|
<kendo-textbox class="oe__control" [(ngModel)]="quickAdd.lastName_zh" size="large"></kendo-textbox>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="oe__field">
|
||||||
|
<label class="oe__label">公司行號 · Company</label>
|
||||||
|
<kendo-textbox class="oe__control" [(ngModel)]="quickAdd.entity" size="large"></kendo-textbox>
|
||||||
|
</div>
|
||||||
<div class="oe__field">
|
<div class="oe__field">
|
||||||
<label class="oe__label">手機 · Cell phone</label>
|
<label class="oe__label">手機 · Cell phone</label>
|
||||||
<kendo-textbox class="oe__control" [(ngModel)]="quickAdd.phoneCell" size="large"></kendo-textbox>
|
<kendo-textbox class="oe__control" [(ngModel)]="quickAdd.phoneCell" size="large"></kendo-textbox>
|
||||||
|
|||||||
+7
-5
@@ -151,10 +151,11 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
|
|||||||
// is no nick name (or it's the same as the legal first name).
|
// is no nick name (or it's the same as the legal first name).
|
||||||
private giverLabel(m: MemberTypeaheadDto): string {
|
private giverLabel(m: MemberTypeaheadDto): string {
|
||||||
const legal = `${m.firstName_en} ${m.lastName_en}`.trim();
|
const legal = `${m.firstName_en} ${m.lastName_en}`.trim();
|
||||||
if (m.nickName && m.nickName !== m.firstName_en) {
|
const base = (m.nickName && m.nickName !== m.firstName_en)
|
||||||
return `${m.nickName} ${m.lastName_en} (${legal})`;
|
? `${m.nickName} ${m.lastName_en} (${legal})`
|
||||||
}
|
: legal;
|
||||||
return legal;
|
// Append the company / business name so a company-check giver is unambiguous.
|
||||||
|
return m.entity ? `${base} · ${m.entity}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMemberSelected(id: number | null): void {
|
onMemberSelected(id: number | null): void {
|
||||||
@@ -206,6 +207,7 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
|
|||||||
nickName: this.trimToNull(this.quickAdd.nickName),
|
nickName: this.trimToNull(this.quickAdd.nickName),
|
||||||
firstName_zh: this.trimToNull(this.quickAdd.firstName_zh),
|
firstName_zh: this.trimToNull(this.quickAdd.firstName_zh),
|
||||||
lastName_zh: this.trimToNull(this.quickAdd.lastName_zh),
|
lastName_zh: this.trimToNull(this.quickAdd.lastName_zh),
|
||||||
|
entity: this.trimToNull(this.quickAdd.entity),
|
||||||
phoneCell: this.trimToNull(this.quickAdd.phoneCell),
|
phoneCell: this.trimToNull(this.quickAdd.phoneCell),
|
||||||
};
|
};
|
||||||
this.api.quickAddMember(request).subscribe({
|
this.api.quickAddMember(request).subscribe({
|
||||||
@@ -229,7 +231,7 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
|
|||||||
private blankQuickAdd(): QuickAddMemberRequest {
|
private blankQuickAdd(): QuickAddMemberRequest {
|
||||||
return {
|
return {
|
||||||
firstName_en: '', lastName_en: '', nickName: null,
|
firstName_en: '', lastName_en: '', nickName: null,
|
||||||
firstName_zh: null, lastName_zh: null, phoneCell: null,
|
firstName_zh: null, lastName_zh: null, entity: null, phoneCell: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-6
@@ -36,7 +36,7 @@
|
|||||||
<span class="card__zh">最近的奉獻紀錄</span>
|
<span class="card__zh">最近的奉獻紀錄</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<kendo-grid class="lined" [data]="sessions">
|
<kendo-grid class="lined clickable-rows" [data]="sessions" (cellClick)="onSessionCellClick($event)">
|
||||||
<kendo-grid-column field="sessionDate" title="Date" [width]="120"></kendo-grid-column>
|
<kendo-grid-column field="sessionDate" title="Date" [width]="120"></kendo-grid-column>
|
||||||
<kendo-grid-column title="Status" [width]="130">
|
<kendo-grid-column title="Status" [width]="130">
|
||||||
<ng-template kendoGridCellTemplate let-s>
|
<ng-template kendoGridCellTemplate let-s>
|
||||||
@@ -44,6 +44,9 @@
|
|||||||
</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="Attendance · 主日人數" [width]="140">
|
||||||
|
<ng-template kendoGridCellTemplate let-s>{{ s.sundayAttendanceCount ?? '—' }}</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
<kendo-grid-column title="Proof" [width]="70">
|
<kendo-grid-column title="Proof" [width]="70">
|
||||||
<ng-template kendoGridCellTemplate let-s>
|
<ng-template kendoGridCellTemplate let-s>
|
||||||
<span *ngIf="s.hasProof" title="Paper proof attached · 已附證明">📎</span>
|
<span *ngIf="s.hasProof" title="Paper proof attached · 已附證明">📎</span>
|
||||||
@@ -51,15 +54,12 @@
|
|||||||
</kendo-grid-column>
|
</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">
|
|
||||||
<ng-template kendoGridCellTemplate let-s>
|
|
||||||
<button kendoButton fillMode="flat" themeColor="primary" (click)="openView(s)">View</button>
|
|
||||||
</ng-template>
|
|
||||||
</kendo-grid-column>
|
|
||||||
<ng-template kendoGridNoRecordsTemplate>
|
<ng-template kendoGridNoRecordsTemplate>
|
||||||
<div class="empty">No sessions yet — pick a date above to start.<br><span>尚無紀錄 — 選擇上方日期開始</span></div>
|
<div class="empty">No sessions yet — pick a date above to start.<br><span>尚無紀錄 — 選擇上方日期開始</span></div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</kendo-grid>
|
</kendo-grid>
|
||||||
|
<kendo-contextmenu #sessionMenu [items]="sessionMenuItems" (select)="onSessionMenuSelect($event)"></kendo-contextmenu>
|
||||||
|
<div class="hint-text-sm">點一列檢視 · 右鍵修改主日人數 / Click a row to view · right-click to edit attendance</div>
|
||||||
</section>
|
</section>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
@@ -306,4 +306,25 @@
|
|||||||
|
|
||||||
<app-member-quick-add-dialog *ngIf="showQuickAdd" (created)="onMemberQuickCreated($event)"
|
<app-member-quick-add-dialog *ngIf="showQuickAdd" (created)="onMemberQuickCreated($event)"
|
||||||
(cancelled)="showQuickAdd = false"></app-member-quick-add-dialog>
|
(cancelled)="showQuickAdd = false"></app-member-quick-add-dialog>
|
||||||
|
|
||||||
|
<!-- ============================ EDIT SUNDAY ATTENDANCE ============================ -->
|
||||||
|
<kendo-dialog *ngIf="attDialogOpen" title="修改主日參加人數 · Edit Sunday Attendance"
|
||||||
|
(close)="attDialogOpen = false" [width]="440" [maxWidth]="'95vw'">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
|
||||||
|
<label class="flex flex-col gap-1">成人 Adult
|
||||||
|
<kendo-numerictextbox [(ngModel)]="attForm.adult" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">青年 Youth
|
||||||
|
<kendo-numerictextbox [(ngModel)]="attForm.youth" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">兒童 Kid
|
||||||
|
<kendo-numerictextbox [(ngModel)]="attForm.kid" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="att-total">總數 Total: {{ attTotal }}</div>
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="attDialogOpen = false">Cancel</button>
|
||||||
|
<button kendoButton themeColor="primary" [disabled]="attSaving" (click)="saveAttendance()">Save</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+10
@@ -233,3 +233,13 @@
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.rise { animation: none; opacity: 1; transform: none; }
|
.rise { animation: none; opacity: 1; transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clickable-rows {
|
||||||
|
.k-grid-table tr { cursor: pointer; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-total {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|||||||
+72
-7
@@ -1,14 +1,16 @@
|
|||||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
import { Component, OnDestroy, OnInit, ViewChild } 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, Subject, from, of, map, switchMap, takeUntil } from 'rxjs';
|
import { Observable, Subject, from, of, map, switchMap, takeUntil } from 'rxjs';
|
||||||
import { buildProofPdf } from '../../services/proof-pdf.builder';
|
import { buildProofPdf } from '../../services/proof-pdf.builder';
|
||||||
import { GridModule } from '@progress/kendo-angular-grid';
|
import { GridModule, CellClickEvent } 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';
|
||||||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||||
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
||||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||||
|
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
|
||||||
|
import { MealAttendanceApiService } from '../../../meal-attendance/services/meal-attendance-api.service';
|
||||||
import { OfferingSessionApiService } from '../../services/offering-session-api.service';
|
import { OfferingSessionApiService } from '../../services/offering-session-api.service';
|
||||||
import { OfferingEntrySignalrService } from '../../services/offering-entry-signalr.service';
|
import { OfferingEntrySignalrService } from '../../services/offering-entry-signalr.service';
|
||||||
import { GivingCategoryApiService } from '../../services/giving-category-api.service';
|
import { GivingCategoryApiService } from '../../services/giving-category-api.service';
|
||||||
@@ -30,7 +32,7 @@ type PageMode = 'landing' | 'workspace' | 'view';
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule,
|
CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule,
|
||||||
DropDownsModule, DateInputsModule, DialogsModule, MemberQuickAddDialogComponent,
|
DropDownsModule, DateInputsModule, DialogsModule, ContextMenuModule, MemberQuickAddDialogComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './offering-session-page.component.html',
|
templateUrl: './offering-session-page.component.html',
|
||||||
styleUrls: ['./offering-session-page.component.scss'],
|
styleUrls: ['./offering-session-page.component.scss'],
|
||||||
@@ -74,12 +76,25 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
|
|||||||
viewSession: OfferingSessionDto | null = null;
|
viewSession: OfferingSessionDto | null = null;
|
||||||
confirmReopenOpen = false;
|
confirmReopenOpen = false;
|
||||||
|
|
||||||
|
// Right-click actions on a Recent Sessions row.
|
||||||
|
@ViewChild('sessionMenu') sessionMenu!: ContextMenuComponent;
|
||||||
|
readonly sessionMenuItems = [{ text: 'View / 檢視' }, { text: '修改主日人數' }];
|
||||||
|
private contextSession: OfferingSessionListItemDto | null = null;
|
||||||
|
|
||||||
|
// Edit Sunday attendance dialog.
|
||||||
|
attDialogOpen = false;
|
||||||
|
attSaving = false;
|
||||||
|
private attDate: string | null = null; // yyyy-MM-dd of the session being edited
|
||||||
|
attForm = { adult: 0, youth: 0, kid: 0 };
|
||||||
|
get attTotal(): number { return this.attForm.adult + this.attForm.youth + this.attForm.kid; }
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private api: OfferingSessionApiService,
|
private api: OfferingSessionApiService,
|
||||||
private categoryApi: GivingCategoryApiService,
|
private categoryApi: GivingCategoryApiService,
|
||||||
private memberApi: MemberApiService,
|
private memberApi: MemberApiService,
|
||||||
private signalr: OfferingEntrySignalrService,
|
private signalr: OfferingEntrySignalrService,
|
||||||
) {}
|
private mealAttendanceApi: MealAttendanceApiService,
|
||||||
|
) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.categoryApi.getAll(false).subscribe(c => {
|
this.categoryApi.getAll(false).subscribe(c => {
|
||||||
@@ -162,6 +177,55 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
|
|||||||
this.api.getPaged(1, 20).subscribe(r => this.sessions = r.items);
|
this.api.getPaged(1, 20).subscribe(r => this.sessions = r.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Left-click anywhere on a row opens it; right-click opens the actions menu.
|
||||||
|
onSessionCellClick(event: CellClickEvent): void {
|
||||||
|
if (event.type === 'contextmenu') {
|
||||||
|
event.originalEvent.preventDefault();
|
||||||
|
this.contextSession = event.dataItem;
|
||||||
|
this.sessionMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
|
||||||
|
} else {
|
||||||
|
this.openView(event.dataItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSessionMenuSelect(event: ContextMenuSelectEvent): void {
|
||||||
|
const session = this.contextSession;
|
||||||
|
if (!session) return;
|
||||||
|
if (event.item.text === 'View / 檢視') this.openView(session);
|
||||||
|
else if (event.item.text === '修改主日人數') this.openAttendanceEdit(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the attendance editor, prefilling the three age groups from the existing row (zeros if none).
|
||||||
|
openAttendanceEdit(session: OfferingSessionListItemDto): void {
|
||||||
|
this.attDate = session.sessionDate;
|
||||||
|
this.attForm = { adult: 0, youth: 0, kid: 0 };
|
||||||
|
this.attSaving = false;
|
||||||
|
this.attDialogOpen = true;
|
||||||
|
this.mealAttendanceApi.getRange(session.sessionDate, session.sessionDate).subscribe(rows => {
|
||||||
|
const row = rows[0];
|
||||||
|
if (row) this.attForm = { adult: row.adult, youth: row.youth, kid: row.kid };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAttendance(): void {
|
||||||
|
if (!this.attDate) return;
|
||||||
|
const date = this.attDate;
|
||||||
|
this.attSaving = true;
|
||||||
|
this.mealAttendanceApi.setCounts(date, this.attForm).subscribe({
|
||||||
|
next: counts => {
|
||||||
|
const total = counts.adult + counts.youth + counts.kid;
|
||||||
|
const row = this.sessions.find(s => s.sessionDate === date);
|
||||||
|
if (row) row.sundayAttendanceCount = total;
|
||||||
|
this.attDialogOpen = false;
|
||||||
|
this.attSaving = false;
|
||||||
|
},
|
||||||
|
error: (err: { error?: { message?: string } }) => {
|
||||||
|
this.attSaving = false;
|
||||||
|
alert(err?.error?.message ?? 'Save failed.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Flow: landing → workspace / view ──────────────────────────────────────
|
// ── Flow: landing → workspace / view ──────────────────────────────────────
|
||||||
|
|
||||||
/** Free date chosen on the landing screen — begin a brand-new session. */
|
/** Free date chosen on the landing screen — begin a brand-new session. */
|
||||||
@@ -275,7 +339,7 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
|
|||||||
clearAnonymous(): void {
|
clearAnonymous(): void {
|
||||||
this.entry.isAnonymous = false;
|
this.entry.isAnonymous = false;
|
||||||
}
|
}
|
||||||
|
lastAddedLine: OfferingBufferLine | null = null;
|
||||||
addLine(): void {
|
addLine(): void {
|
||||||
if (this.entry.amount <= 0) return;
|
if (this.entry.amount <= 0) return;
|
||||||
if (this.entry.paymentMethod === 'Check' && !this.entry.checkNumber) return;
|
if (this.entry.paymentMethod === 'Check' && !this.entry.checkNumber) return;
|
||||||
@@ -287,6 +351,7 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
if (this.editingIndex !== null) { this.buffer[this.editingIndex] = line; this.editingIndex = null; }
|
if (this.editingIndex !== null) { this.buffer[this.editingIndex] = line; this.editingIndex = null; }
|
||||||
else { this.buffer = [...this.buffer, line]; }
|
else { this.buffer = [...this.buffer, line]; }
|
||||||
|
this.lastAddedLine = line;
|
||||||
this.resetEntry();
|
this.resetEntry();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +406,7 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
|
|||||||
switchMap(id => this.pendingProofFiles.length === 0
|
switchMap(id => this.pendingProofFiles.length === 0
|
||||||
? of(void 0)
|
? of(void 0)
|
||||||
: from(buildProofPdf(this.pendingProofFiles)).pipe(
|
: from(buildProofPdf(this.pendingProofFiles)).pipe(
|
||||||
switchMap(({ blob }) => this.api.uploadProof(id, blob)))),
|
switchMap(({ blob }) => this.api.uploadProof(id, blob)))),
|
||||||
).subscribe({
|
).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.submitting = false;
|
this.submitting = false;
|
||||||
@@ -439,7 +504,7 @@ export class OfferingSessionPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private blankEntry(): OfferingBufferLine {
|
private blankEntry(): OfferingBufferLine {
|
||||||
return {
|
return {
|
||||||
memberId: null, givingCategoryId: 0, amount: 0, paymentMethod: 'Cash',
|
memberId: null, givingCategoryId: this.lastAddedLine?.givingCategoryId, amount: 0, paymentMethod: this.lastAddedLine?.paymentMethod ?? 'Cash',
|
||||||
checkNumber: null, zelleReferenceCode: null, payPalTransactionId: null,
|
checkNumber: null, zelleReferenceCode: null, payPalTransactionId: null,
|
||||||
isAnonymous: false, notes: null, memberName: null, categoryName: '',
|
isAnonymous: false, notes: null, memberName: null, categoryName: '',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,4 +22,9 @@ export class MealAttendanceApiService {
|
|||||||
const params = new HttpParams().set('from', from).set('to', to);
|
const params = new HttpParams().set('from', from).set('to', to);
|
||||||
return this.http.get<AttendanceCounts[]>(this.endpoint, { params });
|
return this.http.get<AttendanceCounts[]>(this.endpoint, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Overwrite a specific Sunday's counts (back-office editor). */
|
||||||
|
setCounts(date: string, counts: { adult: number; youth: number; kid: number }): Observable<AttendanceCounts> {
|
||||||
|
return this.http.put<AttendanceCounts>(`${this.endpoint}/${date}`, counts);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
<kendo-dialog title="Invitation Link" (close)="onClose()" [width]="560" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
|
||||||
|
|
||||||
|
<!-- Ask for an email when the member has none on file -->
|
||||||
|
<ng-container *ngIf="step === 'needEmail'">
|
||||||
|
<p class="k-mb-4">
|
||||||
|
Create a first-login invitation for <strong>{{ memberName }}</strong>.
|
||||||
|
This member has no email on file — enter one to use as their login.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form [formGroup]="emailForm" (ngSubmit)="generate()" class="k-form k-form-vertical">
|
||||||
|
<kendo-formfield>
|
||||||
|
<kendo-label text="Login Email *"></kendo-label>
|
||||||
|
<kendo-textbox formControlName="email"></kendo-textbox>
|
||||||
|
<kendo-formerror *ngIf="emailForm.get('email')?.errors?.['required']">Email is required.</kendo-formerror>
|
||||||
|
<kendo-formerror *ngIf="emailForm.get('email')?.errors?.['email']">Invalid email address.</kendo-formerror>
|
||||||
|
</kendo-formfield>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p *ngIf="errorMessage" class="k-color-error k-mt-3">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="onClose()">Cancel</button>
|
||||||
|
<button kendoButton themeColor="primary" (click)="generate()">Create Link</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Generating spinner -->
|
||||||
|
<ng-container *ngIf="step === 'generating'">
|
||||||
|
<div class="k-text-center k-p-4">
|
||||||
|
<kendo-loader></kendo-loader>
|
||||||
|
<p class="k-mt-2">Creating invitation link…</p>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Ready — show link to copy / email -->
|
||||||
|
<ng-container *ngIf="step === 'ready'">
|
||||||
|
<p class="k-mb-3">
|
||||||
|
Send this link to <strong>{{ memberName }}</strong>. They'll set their own password and sign in.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="k-d-flex k-gap-2 k-align-items-center k-mb-2">
|
||||||
|
<kendo-textbox [value]="link" [readonly]="true" style="flex: 1"></kendo-textbox>
|
||||||
|
<button kendoButton (click)="copyLink()">{{ copied ? 'Copied!' : 'Copy' }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="k-font-size-sm k-mb-3">
|
||||||
|
Single use — expires {{ expiresAt | date:'medium' }}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button kendoButton themeColor="info" (click)="sendEmail()" [disabled]="isSending">
|
||||||
|
<span *ngIf="isSending">…</span>
|
||||||
|
Send via email
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton themeColor="primary" (click)="onClose()">Done</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
</kendo-dialog>
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||||
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
|
import { LabelModule } from '@progress/kendo-angular-label';
|
||||||
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
|
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
|
||||||
|
import { MemberListItemDto, memberDisplayName } from '../../models/member.model';
|
||||||
|
import { InvitationApiService } from '../../services/invitation-api.service';
|
||||||
|
import { ToastService } from '../../../../core/services/toast.service';
|
||||||
|
|
||||||
|
type Step = 'needEmail' | 'generating' | 'ready';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-invitation-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule, ReactiveFormsModule, DialogsModule, InputsModule,
|
||||||
|
LabelModule, ButtonsModule, IndicatorsModule,
|
||||||
|
],
|
||||||
|
templateUrl: './invitation-dialog.component.html',
|
||||||
|
})
|
||||||
|
export class InvitationDialogComponent implements OnInit {
|
||||||
|
@Input({ required: true }) member!: MemberListItemDto;
|
||||||
|
@Output() cancelled = new EventEmitter<void>();
|
||||||
|
|
||||||
|
step: Step = 'generating';
|
||||||
|
emailForm!: FormGroup;
|
||||||
|
|
||||||
|
link = '';
|
||||||
|
expiresAt: string | null = null;
|
||||||
|
copied = false;
|
||||||
|
isSending = false;
|
||||||
|
errorMessage = '';
|
||||||
|
|
||||||
|
get memberName(): string { return memberDisplayName(this.member); }
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private invitationApi: InvitationApiService,
|
||||||
|
private toast: ToastService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.emailForm = this.fb.group({
|
||||||
|
email: [this.member.email ?? '', [Validators.required, Validators.email]],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-generate when the member already has an email; otherwise ask for one first.
|
||||||
|
if (this.member.email) {
|
||||||
|
this.generate();
|
||||||
|
} else {
|
||||||
|
this.step = 'needEmail';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate (or re-issue) the link. Uses the form email when the member has none on file. */
|
||||||
|
generate(): void {
|
||||||
|
if (this.step === 'needEmail') {
|
||||||
|
if (this.emailForm.invalid) { this.emailForm.markAllAsTouched(); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = this.member.email ? undefined : this.emailForm.value.email;
|
||||||
|
this.step = 'generating';
|
||||||
|
this.errorMessage = '';
|
||||||
|
|
||||||
|
this.invitationApi.create(this.member.id, email).subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
this.link = `${window.location.origin}/accept-invitation?token=${result.token}`;
|
||||||
|
this.expiresAt = result.expiresAt;
|
||||||
|
this.step = 'ready';
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.errorMessage = err.error?.message ?? 'Failed to create the invitation link.';
|
||||||
|
// Fall back to the email step so the admin can supply/correct an address.
|
||||||
|
this.step = 'needEmail';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
copyLink(): void {
|
||||||
|
navigator.clipboard.writeText(this.link).then(() => {
|
||||||
|
this.copied = true;
|
||||||
|
setTimeout(() => (this.copied = false), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEmail(): void {
|
||||||
|
this.isSending = true;
|
||||||
|
this.invitationApi.sendEmail(this.member.id, this.link).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toast.success(`Invitation emailed to ${this.memberName}.`);
|
||||||
|
this.isSending = false;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.toast.error(err.error?.message ?? 'Failed to send the email.');
|
||||||
|
this.isSending = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(): void {
|
||||||
|
this.cancelled.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -33,6 +33,11 @@
|
|||||||
<kendo-textbox formControlName="lastName_zh"></kendo-textbox>
|
<kendo-textbox formControlName="lastName_zh"></kendo-textbox>
|
||||||
</kendo-formfield>
|
</kendo-formfield>
|
||||||
|
|
||||||
|
<kendo-formfield class="md:col-span-2">
|
||||||
|
<kendo-label text="公司行號 · Company / Entity"></kendo-label>
|
||||||
|
<kendo-textbox formControlName="entity" placeholder="e.g. ABC Trading Inc."></kendo-textbox>
|
||||||
|
</kendo-formfield>
|
||||||
|
|
||||||
<kendo-formfield>
|
<kendo-formfield>
|
||||||
<kendo-label text="Gender"></kendo-label>
|
<kendo-label text="Gender"></kendo-label>
|
||||||
<kendo-dropdownlist
|
<kendo-dropdownlist
|
||||||
|
|||||||
+1
@@ -43,6 +43,7 @@ export class MemberFormDialogComponent implements OnInit {
|
|||||||
nickName: [this.member?.nickName ?? null, Validators.maxLength(100)],
|
nickName: [this.member?.nickName ?? null, Validators.maxLength(100)],
|
||||||
firstName_zh: [this.member?.firstName_zh ?? null, Validators.maxLength(100)],
|
firstName_zh: [this.member?.firstName_zh ?? null, Validators.maxLength(100)],
|
||||||
lastName_zh: [this.member?.lastName_zh ?? null, Validators.maxLength(100)],
|
lastName_zh: [this.member?.lastName_zh ?? null, Validators.maxLength(100)],
|
||||||
|
entity: [this.member?.entity ?? null, Validators.maxLength(200)],
|
||||||
gender: [this.member?.gender ?? null],
|
gender: [this.member?.gender ?? null],
|
||||||
dateOfBirth: [this.member?.dateOfBirth ?? null],
|
dateOfBirth: [this.member?.dateOfBirth ?? null],
|
||||||
status: [this.member?.status ?? 'Member', Validators.required],
|
status: [this.member?.status ?? 'Member', Validators.required],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface MemberListItemDto {
|
|||||||
nickName: string | null;
|
nickName: string | null;
|
||||||
firstName_zh: string | null;
|
firstName_zh: string | null;
|
||||||
lastName_zh: string | null;
|
lastName_zh: string | null;
|
||||||
|
entity: string | null;
|
||||||
status: MemberStatus;
|
status: MemberStatus;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phoneCell: string | null;
|
phoneCell: string | null;
|
||||||
@@ -39,6 +40,7 @@ export interface CreateMemberRequest {
|
|||||||
nickName: string | null;
|
nickName: string | null;
|
||||||
firstName_zh: string | null;
|
firstName_zh: string | null;
|
||||||
lastName_zh: string | null;
|
lastName_zh: string | null;
|
||||||
|
entity: string | null;
|
||||||
gender: string | null;
|
gender: string | null;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
baptismDate: string | null;
|
baptismDate: string | null;
|
||||||
|
|||||||
@@ -63,13 +63,15 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</kendo-grid-column>
|
</kendo-grid-column>
|
||||||
|
|
||||||
<kendo-grid-column title="Actions" [width]="210">
|
<kendo-grid-column title="Actions" [width]="290">
|
||||||
<ng-template kendoGridCellTemplate let-row>
|
<ng-template kendoGridCellTemplate let-row>
|
||||||
<div class="k-d-flex k-gap-2">
|
<div class="k-d-flex k-gap-2">
|
||||||
<button kendoButton size="small" (click)="openEditDialog(row)">Edit</button>
|
<button kendoButton size="small" (click)="openEditDialog(row)">Edit</button>
|
||||||
<button kendoButton size="small" themeColor="error" (click)="deleteMember(row)">Delete</button>
|
<button kendoButton size="small" themeColor="error" (click)="deleteMember(row)">Delete</button>
|
||||||
<button *ngIf="!row.linkedUserId" kendoButton size="small" themeColor="info"
|
<button *ngIf="!row.linkedUserId" kendoButton size="small" themeColor="info"
|
||||||
(click)="openCreateUserDialog(row)">+ Account</button>
|
(click)="openCreateUserDialog(row)">+ Account</button>
|
||||||
|
<button *appHasPermission="['Users', 'write']" kendoButton size="small" themeColor="warning"
|
||||||
|
(click)="openInviteDialog(row)">Invite</button>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</kendo-grid-column>
|
</kendo-grid-column>
|
||||||
@@ -92,3 +94,10 @@
|
|||||||
(created)="onUserCreated()"
|
(created)="onUserCreated()"
|
||||||
(cancelled)="closeCreateUserDialog()">
|
(cancelled)="closeCreateUserDialog()">
|
||||||
</app-create-user-dialog>
|
</app-create-user-dialog>
|
||||||
|
|
||||||
|
<!-- Invitation Link Dialog -->
|
||||||
|
<app-invitation-dialog
|
||||||
|
*ngIf="showInviteDialog && selectedMemberForInvite"
|
||||||
|
[member]="selectedMemberForInvite"
|
||||||
|
(cancelled)="closeInviteDialog()">
|
||||||
|
</app-invitation-dialog>
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
|||||||
import { MemberApiService } from '../../services/member-api.service';
|
import { MemberApiService } from '../../services/member-api.service';
|
||||||
import { MemberFormDialogComponent } from '../../components/member-form-dialog/member-form-dialog.component';
|
import { MemberFormDialogComponent } from '../../components/member-form-dialog/member-form-dialog.component';
|
||||||
import { CreateUserDialogComponent } from '../../components/create-user-dialog/create-user-dialog.component';
|
import { CreateUserDialogComponent } from '../../components/create-user-dialog/create-user-dialog.component';
|
||||||
|
import { InvitationDialogComponent } from '../../components/invitation-dialog/invitation-dialog.component';
|
||||||
import {
|
import {
|
||||||
MemberListItemDto, MemberDto, CreateMemberRequest,
|
MemberListItemDto, MemberDto, CreateMemberRequest,
|
||||||
PagedResult, memberDisplayName
|
PagedResult, memberDisplayName
|
||||||
} from '../../models/member.model';
|
} from '../../models/member.model';
|
||||||
import { MEMBER_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
|
import { MEMBER_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||||
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||||
|
import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-members-page',
|
selector: 'app-members-page',
|
||||||
@@ -22,7 +24,8 @@ import { PageHeaderActionsDirective } from '../../../../shared/directives/page-h
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule, FormsModule, GridModule, InputsModule,
|
CommonModule, FormsModule, GridModule, InputsModule,
|
||||||
ButtonsModule, IndicatorsModule, DropDownsModule,
|
ButtonsModule, IndicatorsModule, DropDownsModule,
|
||||||
MemberFormDialogComponent, CreateUserDialogComponent, PageHeaderActionsDirective,
|
MemberFormDialogComponent, CreateUserDialogComponent, InvitationDialogComponent,
|
||||||
|
PageHeaderActionsDirective, HasPermissionDirective,
|
||||||
],
|
],
|
||||||
templateUrl: './members-page.component.html',
|
templateUrl: './members-page.component.html',
|
||||||
styleUrls: ['./members-page.component.scss'],
|
styleUrls: ['./members-page.component.scss'],
|
||||||
@@ -46,8 +49,10 @@ export class MembersPageComponent implements OnInit {
|
|||||||
// Dialogs
|
// Dialogs
|
||||||
showMemberDialog = false;
|
showMemberDialog = false;
|
||||||
showCreateUserDialog = false;
|
showCreateUserDialog = false;
|
||||||
|
showInviteDialog = false;
|
||||||
editingMember: MemberDto | null = null;
|
editingMember: MemberDto | null = null;
|
||||||
selectedMemberForUser: MemberListItemDto | null = null;
|
selectedMemberForUser: MemberListItemDto | null = null;
|
||||||
|
selectedMemberForInvite: MemberListItemDto | null = null;
|
||||||
|
|
||||||
readonly memberDisplayName = memberDisplayName;
|
readonly memberDisplayName = memberDisplayName;
|
||||||
|
|
||||||
@@ -139,4 +144,18 @@ export class MembersPageComponent implements OnInit {
|
|||||||
this.closeCreateUserDialog();
|
this.closeCreateUserDialog();
|
||||||
this.loadData();
|
this.loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Invitation Link ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
openInviteDialog(member: MemberListItemDto): void {
|
||||||
|
this.selectedMemberForInvite = member;
|
||||||
|
this.showInviteDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeInviteDialog(): void {
|
||||||
|
this.showInviteDialog = false;
|
||||||
|
this.selectedMemberForInvite = null;
|
||||||
|
// An invitation may have just created an account, so refresh the grid.
|
||||||
|
this.loadData();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ApiConfigService } from '../../../core/services/api-config.service';
|
||||||
|
|
||||||
|
export interface CreateInvitationResult {
|
||||||
|
/** Raw, single-use token — returned once; build the link from it client-side. */
|
||||||
|
token: string;
|
||||||
|
/** ISO timestamp when the link stops working. */
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class InvitationApiService {
|
||||||
|
private readonly endpoint: string;
|
||||||
|
|
||||||
|
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
|
||||||
|
this.endpoint = apiConfig.getApiUrl('invitations');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate (or re-issue) a first-login invitation link for a member. */
|
||||||
|
create(memberId: number, email?: string): Observable<CreateInvitationResult> {
|
||||||
|
return this.http.post<CreateInvitationResult>(this.endpoint, { memberId, email });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** E-mail an already-generated link to the member. */
|
||||||
|
sendEmail(memberId: number, link: string): Observable<void> {
|
||||||
|
return this.http.post<void>(`${this.endpoint}/send`, { memberId, link });
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
<div *ngIf="model" class="max-w-3xl pt-4 flex flex-col gap-6">
|
||||||
|
|
||||||
|
<!-- ── Email (SMTP) ─────────────────────────────────────────────────────── -->
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-base font-semibold">Email (SMTP) / 電子郵件</h3>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
Enabled / 啟用
|
||||||
|
<kendo-switch [(ngModel)]="model.enableEmail"></kendo-switch>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
SMTP Host / 主機
|
||||||
|
<kendo-textbox [(ngModel)]="model.smtpHost" placeholder="smtp.example.com"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Port / 連接埠
|
||||||
|
<kendo-numerictextbox [(ngModel)]="model.smtpPort" [min]="0" [max]="65535" [decimals]="0" format="#"></kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Use SSL / 加密
|
||||||
|
<kendo-switch [(ngModel)]="model.smtpUseSsl"></kendo-switch>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
SMTP User / 帳號
|
||||||
|
<kendo-textbox [(ngModel)]="model.smtpUser"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
SMTP Password / 密碼
|
||||||
|
<kendo-textbox [(ngModel)]="smtpPassword" type="password"
|
||||||
|
[placeholder]="model.hasSmtpPassword ? '•••••• stored (blank = keep) / 已設定' : ''"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
From Address / 寄件地址
|
||||||
|
<kendo-textbox [(ngModel)]="model.fromAddress" placeholder="noreply@church.org"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
From Name / 寄件人名稱
|
||||||
|
<kendo-textbox [(ngModel)]="model.fromName"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<label class="flex flex-col gap-1 grow max-w-xs">
|
||||||
|
<span class="text-sm">Test recipient (blank = you) / 測試收件人</span>
|
||||||
|
<kendo-textbox [(ngModel)]="testEmailTo" placeholder="you@example.com"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<button kendoButton [disabled]="testingEmail" (click)="sendTestEmail()">Send test email / 寄送測試</button>
|
||||||
|
<span class="text-sm" [style.color]="testEmailMsg.startsWith('Sent') ? '#065f46' : '#b91c1c'">{{ testEmailMsg }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="border-gray-200" />
|
||||||
|
|
||||||
|
<!-- ── Line ─────────────────────────────────────────────────────────────── -->
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-base font-semibold">Line / Line 通知</h3>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
Enabled / 啟用
|
||||||
|
<kendo-switch [(ngModel)]="model.enableLine"></kendo-switch>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-y-3">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Channel Access Token / 頻道存取權杖
|
||||||
|
<kendo-textbox [(ngModel)]="lineToken" type="password"
|
||||||
|
[placeholder]="model.hasLineChannelAccessToken ? '•••••• stored (blank = keep) / 已設定' : ''"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Channel Secret / 頻道密鑰
|
||||||
|
<kendo-textbox [(ngModel)]="lineSecret" type="password"
|
||||||
|
[placeholder]="model.hasLineChannelSecret ? '•••••• stored (blank = keep) / 已設定' : ''"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Webhook URL (register in Line console) / Webhook 網址
|
||||||
|
<kendo-textbox [ngModel]="model.webhookUrl" [readonly]="true"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">Test member ID / 會員編號</span>
|
||||||
|
<kendo-numerictextbox [(ngModel)]="testLineMemberId" [decimals]="0" format="#" [min]="1" class="w-40"></kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">or Group ID / 群組編號</span>
|
||||||
|
<kendo-numerictextbox [(ngModel)]="testLineGroupId" [decimals]="0" format="#" [min]="1" class="w-40"></kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
<button kendoButton [disabled]="testingLine" (click)="sendTestLine()">Send test Line / 寄送測試</button>
|
||||||
|
<span class="text-sm" [style.color]="testLineMsg.startsWith('Sent') ? '#065f46' : '#b91c1c'">{{ testLineMsg }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
|
||||||
|
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
|
import { SettingsApiService } from '../../services/settings-api.service';
|
||||||
|
import {
|
||||||
|
NotificationSettingDto, UpdateNotificationSettingRequest, NotificationResult,
|
||||||
|
} from '../../models/settings.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-notification-settings-tab',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule],
|
||||||
|
templateUrl: './notification-settings-tab.component.html',
|
||||||
|
})
|
||||||
|
export class NotificationSettingsTabComponent implements OnInit {
|
||||||
|
model: NotificationSettingDto | null = null;
|
||||||
|
saving = false;
|
||||||
|
savedMsg = '';
|
||||||
|
|
||||||
|
// Write-only secret inputs — blank means "keep the stored value".
|
||||||
|
smtpPassword = '';
|
||||||
|
lineToken = '';
|
||||||
|
lineSecret = '';
|
||||||
|
|
||||||
|
// Test-send state.
|
||||||
|
testEmailTo = '';
|
||||||
|
testEmailMsg = '';
|
||||||
|
testingEmail = false;
|
||||||
|
|
||||||
|
testLineMemberId: number | null = null;
|
||||||
|
testLineGroupId: number | null = null;
|
||||||
|
testLineMsg = '';
|
||||||
|
testingLine = false;
|
||||||
|
|
||||||
|
constructor(private api: SettingsApiService) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
this.api.getNotification().subscribe(n => (this.model = n));
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
if (!this.model || this.saving) return;
|
||||||
|
this.saving = true;
|
||||||
|
this.savedMsg = '';
|
||||||
|
const m = this.model;
|
||||||
|
const request: UpdateNotificationSettingRequest = {
|
||||||
|
enableEmail: m.enableEmail,
|
||||||
|
smtpHost: m.smtpHost,
|
||||||
|
smtpPort: m.smtpPort,
|
||||||
|
smtpUseSsl: m.smtpUseSsl,
|
||||||
|
smtpUser: m.smtpUser,
|
||||||
|
fromAddress: m.fromAddress,
|
||||||
|
fromName: m.fromName,
|
||||||
|
smtpPassword: this.smtpPassword || null,
|
||||||
|
enableLine: m.enableLine,
|
||||||
|
lineChannelAccessToken: this.lineToken || null,
|
||||||
|
lineChannelSecret: this.lineSecret || null,
|
||||||
|
};
|
||||||
|
this.api.updateNotification(request).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.saving = false;
|
||||||
|
this.savedMsg = 'Saved / 已儲存';
|
||||||
|
// Clear secret inputs and refresh the "configured" flags.
|
||||||
|
this.smtpPassword = this.lineToken = this.lineSecret = '';
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: () => { this.saving = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTestEmail(): void {
|
||||||
|
if (this.testingEmail) return;
|
||||||
|
this.testingEmail = true;
|
||||||
|
this.testEmailMsg = '';
|
||||||
|
this.api.testEmail({ toAddress: this.testEmailTo || null }).subscribe({
|
||||||
|
next: result => { this.testingEmail = false; this.testEmailMsg = this.describe(result); },
|
||||||
|
error: err => { this.testingEmail = false; this.testEmailMsg = this.errorText(err); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTestLine(): void {
|
||||||
|
if (this.testingLine) return;
|
||||||
|
this.testingLine = true;
|
||||||
|
this.testLineMsg = '';
|
||||||
|
this.api.testLine({ memberId: this.testLineMemberId, groupId: this.testLineGroupId }).subscribe({
|
||||||
|
next: result => { this.testingLine = false; this.testLineMsg = this.describe(result); },
|
||||||
|
error: err => { this.testingLine = false; this.testLineMsg = this.errorText(err); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private describe(result: NotificationResult): string {
|
||||||
|
if (result.failedCount > 0)
|
||||||
|
return `Failed / 失敗:${result.failures.map(f => f.error).join('; ')}`;
|
||||||
|
return result.sentCount > 0 ? 'Sent ✓ / 已發送' : 'Nothing sent / 未發送';
|
||||||
|
}
|
||||||
|
|
||||||
|
private errorText(err: { error?: { message?: string } }): string {
|
||||||
|
return err?.error?.message ?? 'Failed / 失敗';
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
<div *ngIf="model" class="max-w-3xl pt-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Site Title (EN) / 網站名稱
|
||||||
|
<kendo-textbox [(ngModel)]="model.siteTitle"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Site Title (ZH) / 網站名稱(中)
|
||||||
|
<kendo-textbox [(ngModel)]="model.siteTitleZh"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Default Language / 預設語言
|
||||||
|
<kendo-dropdownlist
|
||||||
|
[data]="languages" textField="text" valueField="value" [valuePrimitive]="true"
|
||||||
|
[(ngModel)]="model.defaultLanguage"></kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Time Zone / 時區
|
||||||
|
<kendo-textbox [(ngModel)]="model.timeZone" placeholder="America/Los_Angeles"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Date Format / 日期格式
|
||||||
|
<kendo-textbox [(ngModel)]="model.dateFormat" placeholder="yyyy-MM-dd"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Currency / 貨幣
|
||||||
|
<kendo-textbox [(ngModel)]="model.currency" placeholder="USD"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 mt-4">
|
||||||
|
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
|
||||||
|
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
|
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||||
|
import { SettingsApiService } from '../../services/settings-api.service';
|
||||||
|
import { SiteSettingDto } from '../../models/settings.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-site-settings-tab',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule, DropDownsModule],
|
||||||
|
templateUrl: './site-settings-tab.component.html',
|
||||||
|
})
|
||||||
|
export class SiteSettingsTabComponent implements OnInit {
|
||||||
|
model: SiteSettingDto | null = null;
|
||||||
|
saving = false;
|
||||||
|
savedMsg = '';
|
||||||
|
|
||||||
|
readonly languages = [
|
||||||
|
{ text: 'English', value: 'en' },
|
||||||
|
{ text: '中文 Chinese', value: 'zh' },
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor(private api: SettingsApiService) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.api.getSite().subscribe(s => (this.model = s));
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
if (!this.model || this.saving) return;
|
||||||
|
this.saving = true;
|
||||||
|
this.savedMsg = '';
|
||||||
|
this.api.updateSite(this.model).subscribe({
|
||||||
|
next: () => { this.saving = false; this.savedMsg = 'Saved / 已儲存'; },
|
||||||
|
// Errors surface globally via httpErrorInterceptor.
|
||||||
|
error: () => { this.saving = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Mirrors ROLAC.API.DTOs.Settings — site + notification settings edited from the
|
||||||
|
// Church Profile tabbed page (Settings permission module).
|
||||||
|
|
||||||
|
export interface SiteSettingDto {
|
||||||
|
siteTitle: string;
|
||||||
|
siteTitleZh: string | null;
|
||||||
|
defaultLanguage: string; // 'en' | 'zh'
|
||||||
|
timeZone: string;
|
||||||
|
dateFormat: string;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateSiteSettingRequest = SiteSettingDto;
|
||||||
|
|
||||||
|
export interface NotificationSettingDto {
|
||||||
|
enableEmail: boolean;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
smtpUseSsl: boolean;
|
||||||
|
smtpUser: string;
|
||||||
|
fromAddress: string;
|
||||||
|
fromName: string;
|
||||||
|
/** True when a password is stored — secrets themselves are never returned. */
|
||||||
|
hasSmtpPassword: boolean;
|
||||||
|
|
||||||
|
enableLine: boolean;
|
||||||
|
hasLineChannelAccessToken: boolean;
|
||||||
|
hasLineChannelSecret: boolean;
|
||||||
|
|
||||||
|
/** Read-only webhook URL to register in the Line console. */
|
||||||
|
webhookUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateNotificationSettingRequest {
|
||||||
|
enableEmail: boolean;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
smtpUseSsl: boolean;
|
||||||
|
smtpUser: string;
|
||||||
|
fromAddress: string | null;
|
||||||
|
fromName: string | null;
|
||||||
|
/** Leave blank/omit to keep the stored password. */
|
||||||
|
smtpPassword?: string | null;
|
||||||
|
|
||||||
|
enableLine: boolean;
|
||||||
|
/** Leave blank/omit to keep the stored token. */
|
||||||
|
lineChannelAccessToken?: string | null;
|
||||||
|
/** Leave blank/omit to keep the stored secret. */
|
||||||
|
lineChannelSecret?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestEmailRequest {
|
||||||
|
toAddress?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestLineRequest {
|
||||||
|
memberId?: number | null;
|
||||||
|
groupId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors ROLAC.API NotificationResult. */
|
||||||
|
export interface NotificationResult {
|
||||||
|
sentCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
failures: { target: string; error: string }[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ApiConfigService } from '../../../core/services/api-config.service';
|
||||||
|
import {
|
||||||
|
SiteSettingDto, UpdateSiteSettingRequest,
|
||||||
|
NotificationSettingDto, UpdateNotificationSettingRequest,
|
||||||
|
TestEmailRequest, TestLineRequest, NotificationResult,
|
||||||
|
} from '../models/settings.model';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SettingsApiService {
|
||||||
|
private readonly endpoint: string;
|
||||||
|
|
||||||
|
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
|
||||||
|
this.endpoint = apiConfig.getApiUrl('settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
getSite(): Observable<SiteSettingDto> {
|
||||||
|
return this.http.get<SiteSettingDto>(`${this.endpoint}/site`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSite(request: UpdateSiteSettingRequest): Observable<void> {
|
||||||
|
return this.http.put<void>(`${this.endpoint}/site`, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNotification(): Observable<NotificationSettingDto> {
|
||||||
|
return this.http.get<NotificationSettingDto>(`${this.endpoint}/notification`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNotification(request: UpdateNotificationSettingRequest): Observable<void> {
|
||||||
|
return this.http.put<void>(`${this.endpoint}/notification`, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
testEmail(request: TestEmailRequest): Observable<NotificationResult> {
|
||||||
|
return this.http.post<NotificationResult>(`${this.endpoint}/notification/test-email`, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
testLine(request: TestLineRequest): Observable<NotificationResult> {
|
||||||
|
return this.http.post<NotificationResult>(`${this.endpoint}/notification/test-line`, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,10 @@ export class UserHeaderComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getDisplayName(): string {
|
public getDisplayName(): string {
|
||||||
|
const member = this.currentUser?.memberInfo;
|
||||||
|
if (member) {
|
||||||
|
return `${member.nickName ?? member.firstName_en} ${member.lastName_en}`;
|
||||||
|
}
|
||||||
return this.currentUser?.email || '';
|
return this.currentUser?.email || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ export class DashboardComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDisplayName(): string {
|
getDisplayName(): string {
|
||||||
|
const member = this.currentUser?.memberInfo;
|
||||||
|
if (member) {
|
||||||
|
return `${member.nickName ?? member.firstName_en} ${member.lastName_en}`;
|
||||||
|
}
|
||||||
return this.currentUser?.email || '';
|
return this.currentUser?.email || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -332,6 +332,10 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDisplayName(): string {
|
getDisplayName(): string {
|
||||||
|
const member = this.currentUser?.memberInfo;
|
||||||
|
if (member) {
|
||||||
|
return `${member.nickName ?? member.firstName_en} ${member.lastName_en}`;
|
||||||
|
}
|
||||||
return this.currentUser?.email || '';
|
return this.currentUser?.email || '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,16 @@ import { ModuleActions } from '../../core/models/permission.model';
|
|||||||
|
|
||||||
// ── Public interfaces ─────────────────────────────────────────────────────────
|
// ── Public interfaces ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Matches the C# MemberInfo DTO exactly. */
|
||||||
|
export interface MemberInfo {
|
||||||
|
id: number;
|
||||||
|
nickName: string | null;
|
||||||
|
firstName_en: string;
|
||||||
|
lastName_en: string;
|
||||||
|
firstName_zh: string | null;
|
||||||
|
lastName_zh: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Matches the C# UserInfo DTO exactly. */
|
/** Matches the C# UserInfo DTO exactly. */
|
||||||
export interface UserInfo {
|
export interface UserInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,6 +28,12 @@ export interface UserInfo {
|
|||||||
* camelCase dictionary-key policy). Absent for legacy/secret-link tokens.
|
* camelCase dictionary-key policy). Absent for legacy/secret-link tokens.
|
||||||
*/
|
*/
|
||||||
permissions?: Record<string, ModuleActions>;
|
permissions?: Record<string, ModuleActions>;
|
||||||
|
/**
|
||||||
|
* The church member linked to this account, or absent for admin-only
|
||||||
|
* accounts and accounts whose member record was deleted. Flows through
|
||||||
|
* login, refresh, and /me so the greeting survives a page reload.
|
||||||
|
*/
|
||||||
|
memberInfo?: MemberInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Matches the C# LoginResponse DTO exactly. */
|
/** Matches the C# LoginResponse DTO exactly. */
|
||||||
@@ -48,6 +64,14 @@ export interface LoginResult {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Matches the C# ValidateInvitationResult DTO. */
|
||||||
|
export interface ValidateInvitationResult {
|
||||||
|
valid: boolean;
|
||||||
|
expired: boolean;
|
||||||
|
memberName?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TokenVerificationResult {
|
export interface TokenVerificationResult {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
/** Constructed from JWT claims when using secret-link login. */
|
/** Constructed from JWT claims when using secret-link login. */
|
||||||
@@ -161,6 +185,36 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether an invitation token is still usable (anonymous). Used by the
|
||||||
|
* public "set your password" page to decide what to show before the member types.
|
||||||
|
*/
|
||||||
|
validateInvitation(token: string): Observable<ValidateInvitationResult> {
|
||||||
|
return this.http.get<ValidateInvitationResult>(
|
||||||
|
`${this.apiConfig.authUrl}/invitation/validate`,
|
||||||
|
{ params: { token } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consumes an invitation: sets the password and logs the member in. On success the
|
||||||
|
* server returns a normal login payload, so we store the access token + user (and the
|
||||||
|
* refresh cookie is set server-side) exactly like login(). Errors propagate to the caller.
|
||||||
|
*/
|
||||||
|
acceptInvitation(token: string, newPassword: string): Observable<UserInfo> {
|
||||||
|
return this.http.post<ApiLoginResponse>(
|
||||||
|
`${this.apiConfig.authUrl}/accept-invitation`,
|
||||||
|
{ token, newPassword },
|
||||||
|
{ withCredentials: true }
|
||||||
|
).pipe(
|
||||||
|
tap(response => {
|
||||||
|
this.accessToken$.next(response.accessToken);
|
||||||
|
this.currentUser$.next(response.user);
|
||||||
|
}),
|
||||||
|
map(response => response.user)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears in-memory auth state immediately, then fires a fire-and-forget
|
* Clears in-memory auth state immediately, then fires a fire-and-forget
|
||||||
* POST to revoke the server-side refresh token cookie.
|
* POST to revoke the server-side refresh token cookie.
|
||||||
|
|||||||
@@ -0,0 +1,537 @@
|
|||||||
|
# Offering Session — 顯示與修改主日參加人數 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 在 finance/offering-session 的 Recent Sessions 表格中,每筆 session 顯示該日的主日參加人數(MealAttendance 三分類之和),並以右鍵 context menu 提供修改該日參加人數的 Action。
|
||||||
|
|
||||||
|
**Architecture:** 後端 `OfferingSessionService.GetPagedAsync` 以 session 的 `SessionDate` 一次性 join `MealAttendance` 求和,填入 DTO 的新 nullable 欄位。修改走新的 REST 端點 `PUT /api/meal-attendance/{date}`(SignalR 的 `SetCount` 只能改本週日,無法改任意日期),由新的 `IMealAttendanceService.SetCountsAsync` 一次寫三欄(load + set + SaveChanges,clamp 至 0,無 row 則建立)。前端在 Kendo grid 加欄、加右鍵選單、加編輯 Dialog。
|
||||||
|
|
||||||
|
**Tech Stack:** C# / EF Core (PostgreSQL, InMemory for tests) / ASP.NET Core / xUnit + Moq;Angular standalone component + Kendo UI (Grid / ContextMenu / Dialog / NumericTextBox)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Backend (modify):**
|
||||||
|
- `API/ROLAC.API/DTOs/MealAttendance/SetAttendanceRequest.cs` — **create**: PUT body `{ adult, youth, kid }`.
|
||||||
|
- `API/ROLAC.API/Services/IMealAttendanceService.cs` — add `SetCountsAsync`.
|
||||||
|
- `API/ROLAC.API/Services/MealAttendanceService.cs` — implement `SetCountsAsync`.
|
||||||
|
- `API/ROLAC.API/Controllers/MealAttendanceController.cs` — add `PUT /{date}`.
|
||||||
|
- `API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs` — add `SundayAttendanceCount`.
|
||||||
|
- `API/ROLAC.API/Services/OfferingSessionService.cs` — populate attendance in `GetPagedAsync`.
|
||||||
|
|
||||||
|
**Backend (tests):**
|
||||||
|
- `API/ROLAC.API.Tests/Services/MealAttendanceServiceTests.cs` — **create**.
|
||||||
|
- `API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs` — add one test.
|
||||||
|
|
||||||
|
**Frontend (modify):**
|
||||||
|
- `APP/src/app/features/giving/models/giving.model.ts` — add `sundayAttendanceCount`.
|
||||||
|
- `APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts` — add `setCounts`.
|
||||||
|
- `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts` — column data, context menu, edit dialog.
|
||||||
|
- `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html` — column, menu, dialog markup.
|
||||||
|
- `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.scss` — total-line style (optional).
|
||||||
|
|
||||||
|
**Build/test commands** (Visual Studio locks `bin/Debug`; always use Release for CLI — per project convention):
|
||||||
|
- Backend tests: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release`
|
||||||
|
- Frontend build: run from `APP/`: `npm run build`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Backend service — `SetCountsAsync`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `API/ROLAC.API/Services/IMealAttendanceService.cs`
|
||||||
|
- Modify: `API/ROLAC.API/Services/MealAttendanceService.cs`
|
||||||
|
- Test: `API/ROLAC.API.Tests/Services/MealAttendanceServiceTests.cs` (create)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `API/ROLAC.API.Tests/Services/MealAttendanceServiceTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class MealAttendanceServiceTests
|
||||||
|
{
|
||||||
|
private static AppDbContext BuildDb() =>
|
||||||
|
new(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetCountsAsync_CreatesRowWhenMissing_AndReturnsTotals()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MealAttendanceService(db);
|
||||||
|
var date = new DateOnly(2026, 5, 31);
|
||||||
|
|
||||||
|
var result = await svc.SetCountsAsync(date, adult: 40, youth: 12, kid: 8);
|
||||||
|
|
||||||
|
Assert.Equal("2026-05-31", result.Date);
|
||||||
|
Assert.Equal(40, result.Adult);
|
||||||
|
Assert.Equal(12, result.Youth);
|
||||||
|
Assert.Equal(8, result.Kid);
|
||||||
|
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetCountsAsync_OverwritesExistingRow_AndClampsNegativesToZero()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MealAttendanceService(db);
|
||||||
|
var date = new DateOnly(2026, 5, 31);
|
||||||
|
await svc.SetCountsAsync(date, 40, 12, 8);
|
||||||
|
|
||||||
|
var result = await svc.SetCountsAsync(date, adult: 50, youth: -3, kid: 0);
|
||||||
|
|
||||||
|
Assert.Equal(50, result.Adult);
|
||||||
|
Assert.Equal(0, result.Youth); // negative clamped to zero
|
||||||
|
Assert.Equal(0, result.Kid);
|
||||||
|
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date)); // still one row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter MealAttendanceServiceTests`
|
||||||
|
Expected: FAIL — compile error, `MealAttendanceService` has no `SetCountsAsync`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the interface method**
|
||||||
|
|
||||||
|
In `API/ROLAC.API/Services/IMealAttendanceService.cs`, add after the existing `SetAsync` declaration (before `GetRangeAsync`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// Overwrites all three age-group columns for <paramref name="date"/> with absolute
|
||||||
|
/// values (each clamped at zero), creating the row if it does not exist, and returns
|
||||||
|
/// the resulting authoritative counts. Used by the back-office Sunday-attendance editor.
|
||||||
|
/// </summary>
|
||||||
|
Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement the method**
|
||||||
|
|
||||||
|
In `API/ROLAC.API/Services/MealAttendanceService.cs`, add after `SetAsync` (before `GetRangeAsync`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid)
|
||||||
|
{
|
||||||
|
var row = await _db.MealAttendances.FirstOrDefaultAsync(a => a.AttendanceDate == date);
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
row = new MealAttendance { AttendanceDate = date };
|
||||||
|
_db.MealAttendances.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts can never be negative; clamp before writing.
|
||||||
|
row.AdultCount = adult < 0 ? 0 : adult;
|
||||||
|
row.YouthCount = youth < 0 ? 0 : youth;
|
||||||
|
row.KidCount = kid < 0 ? 0 : kid;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return ToDto(row);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(`ToDto` and the `MealAttendance` entity are already in this file's scope.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter MealAttendanceServiceTests`
|
||||||
|
Expected: PASS (2 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add API/ROLAC.API/Services/IMealAttendanceService.cs API/ROLAC.API/Services/MealAttendanceService.cs API/ROLAC.API.Tests/Services/MealAttendanceServiceTests.cs
|
||||||
|
git commit -m "feat(attendance): add SetCountsAsync to set all three age groups for a date"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Backend endpoint — `PUT /api/meal-attendance/{date}`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `API/ROLAC.API/DTOs/MealAttendance/SetAttendanceRequest.cs`
|
||||||
|
- Modify: `API/ROLAC.API/Controllers/MealAttendanceController.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the request DTO**
|
||||||
|
|
||||||
|
Create `API/ROLAC.API/DTOs/MealAttendance/SetAttendanceRequest.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ROLAC.API.DTOs.MealAttendance;
|
||||||
|
|
||||||
|
/// <summary>Absolute head-counts to write for one Sunday, from the back-office editor.</summary>
|
||||||
|
public class SetAttendanceRequest
|
||||||
|
{
|
||||||
|
public int Adult { get; set; }
|
||||||
|
public int Youth { get; set; }
|
||||||
|
public int Kid { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the controller action**
|
||||||
|
|
||||||
|
In `API/ROLAC.API/Controllers/MealAttendanceController.cs`, add a `using` for the DTO namespace if not present (it already uses `ROLAC.API.Services`; add `using ROLAC.API.DTOs.MealAttendance;`), then add this action after `GetRange`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>Overwrite a specific Sunday's counts (back-office editor). Authenticated only.</summary>
|
||||||
|
[HttpPut("{date}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> SetCounts(DateOnly date, [FromBody] SetAttendanceRequest body)
|
||||||
|
=> Ok(await _svc.SetCountsAsync(date, body.Adult, body.Youth, body.Kid));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build to verify it compiles**
|
||||||
|
|
||||||
|
Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release`
|
||||||
|
Expected: Build succeeded.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add API/ROLAC.API/DTOs/MealAttendance/SetAttendanceRequest.cs API/ROLAC.API/Controllers/MealAttendanceController.cs
|
||||||
|
git commit -m "feat(attendance): add PUT /api/meal-attendance/{date} to overwrite a Sunday's counts"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Backend — include attendance total in offering session list
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs`
|
||||||
|
- Modify: `API/ROLAC.API/Services/OfferingSessionService.cs:48-55` (the `items` projection in `GetPagedAsync`)
|
||||||
|
- Test: `API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the DTO field**
|
||||||
|
|
||||||
|
In `API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs`, add after `HasProof`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public int? SundayAttendanceCount { get; set; } // null = no attendance recorded for the date
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing test**
|
||||||
|
|
||||||
|
In `API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs`, add this test (helpers `BuildDb`, `BuildAccessor`, `NoOpFileStorage`, `SeedCategoryAsync`, `BuildRequest` already exist in the file; add `using ROLAC.API.Entities;` is already present):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPagedAsync_IncludesSundayAttendanceTotal_WhenRowExists()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var catId = await SeedCategoryAsync(db);
|
||||||
|
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||||
|
|
||||||
|
var withDate = new DateOnly(2026, 5, 31);
|
||||||
|
var withoutDate = new DateOnly(2026, 5, 24);
|
||||||
|
await svc.CreateAsync(BuildRequest(catId, withDate));
|
||||||
|
await svc.CreateAsync(BuildRequest(catId, withoutDate));
|
||||||
|
db.MealAttendances.Add(new MealAttendance
|
||||||
|
{ AttendanceDate = withDate, AdultCount = 40, YouthCount = 12, KidCount = 8 });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var page = await svc.GetPagedAsync(1, 20, null, null);
|
||||||
|
|
||||||
|
var withItem = page.Items.Single(i => i.SessionDate == "2026-05-31");
|
||||||
|
var withoutItem = page.Items.Single(i => i.SessionDate == "2026-05-24");
|
||||||
|
Assert.Equal(60, withItem.SundayAttendanceCount); // 40 + 12 + 8
|
||||||
|
Assert.Null(withoutItem.SundayAttendanceCount); // no attendance row → null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter GetPagedAsync_IncludesSundayAttendanceTotal_WhenRowExists`
|
||||||
|
Expected: FAIL — `SundayAttendanceCount` is null for the row that has attendance (not yet populated).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Populate the field in `GetPagedAsync`**
|
||||||
|
|
||||||
|
In `API/ROLAC.API/Services/OfferingSessionService.cs`, inside `GetPagedAsync`, after the `counts` dictionary block (currently ends at line 46) and before `var items = rows.Select(...)` (line 48), insert:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var dates = rows.Select(r => r.SessionDate).ToList();
|
||||||
|
var attendance = await _db.MealAttendances.AsNoTracking()
|
||||||
|
.Where(a => dates.Contains(a.AttendanceDate))
|
||||||
|
.ToDictionaryAsync(a => a.AttendanceDate, a => a.AdultCount + a.YouthCount + a.KidCount);
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in the `new OfferingSessionListItemDto { ... }` initializer (currently lines 48-55), add this line after `HasProof = s.ProofPdfPath != null,`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
SundayAttendanceCount = attendance.TryGetValue(s.SessionDate, out var att) ? att : (int?)null,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the full test class to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter OfferingSessionServiceTests`
|
||||||
|
Expected: PASS (existing tests + the new one).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs API/ROLAC.API/Services/OfferingSessionService.cs API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs
|
||||||
|
git commit -m "feat(giving): include Sunday attendance total in offering session list"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Frontend — model field + attendance API `setCounts`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `APP/src/app/features/giving/models/giving.model.ts:107-117`
|
||||||
|
- Modify: `APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the model field**
|
||||||
|
|
||||||
|
In `APP/src/app/features/giving/models/giving.model.ts`, in `OfferingSessionListItemDto`, add after `hasProof: boolean;`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
sundayAttendanceCount?: number | null;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the `setCounts` API method**
|
||||||
|
|
||||||
|
In `APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts`, add this method after `getRange`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** Overwrite a specific Sunday's counts (back-office editor). */
|
||||||
|
setCounts(date: string, counts: { adult: number; youth: number; kid: number }): Observable<AttendanceCounts> {
|
||||||
|
return this.http.put<AttendanceCounts>(`${this.endpoint}/${date}`, counts);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add APP/src/app/features/giving/models/giving.model.ts APP/src/app/features/meal-attendance/services/meal-attendance-api.service.ts
|
||||||
|
git commit -m "feat(giving): add sundayAttendanceCount model field and attendance setCounts API"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Frontend — grid column, context menu, edit dialog
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts`
|
||||||
|
- Modify: `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html:39-62`
|
||||||
|
- Modify: `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.scss`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add imports and inject the attendance API (component.ts)**
|
||||||
|
|
||||||
|
In `APP/.../offering-session-page.component.ts`:
|
||||||
|
|
||||||
|
a) Update the grid import to also pull `CellClickEvent` (line 6):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { GridModule, CellClickEvent } from '@progress/kendo-angular-grid';
|
||||||
|
```
|
||||||
|
|
||||||
|
b) Add these imports near the other Kendo imports (after line 11):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
|
||||||
|
import { MealAttendanceApiService } from '../../../meal-attendance/services/meal-attendance-api.service';
|
||||||
|
```
|
||||||
|
|
||||||
|
c) Add `ViewChild` to the `@angular/core` import (line 1):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||||
|
```
|
||||||
|
|
||||||
|
d) Add `ContextMenuModule` to the component `imports` array (line 31-34):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
imports: [
|
||||||
|
CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule,
|
||||||
|
DropDownsModule, DateInputsModule, DialogsModule, ContextMenuModule, MemberQuickAddDialogComponent,
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
e) Inject the attendance API in the constructor (after `private signalr: OfferingEntrySignalrService,` on line 81):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private mealAttendanceApi: MealAttendanceApiService,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add context-menu + dialog state and handlers (component.ts)**
|
||||||
|
|
||||||
|
Add these members after `confirmReopenOpen = false;` (line 75):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Right-click actions on a Recent Sessions row.
|
||||||
|
@ViewChild('sessionMenu') sessionMenu!: ContextMenuComponent;
|
||||||
|
readonly sessionMenuItems = [{ text: 'View / 檢視' }, { text: '修改主日人數' }];
|
||||||
|
private contextSession: OfferingSessionListItemDto | null = null;
|
||||||
|
|
||||||
|
// Edit Sunday attendance dialog.
|
||||||
|
attDialogOpen = false;
|
||||||
|
attSaving = false;
|
||||||
|
private attDate: string | null = null; // yyyy-MM-dd of the session being edited
|
||||||
|
attForm = { adult: 0, youth: 0, kid: 0 };
|
||||||
|
get attTotal(): number { return this.attForm.adult + this.attForm.youth + this.attForm.kid; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Add these methods after `loadSessions()` (line 161-163):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Left-click anywhere on a row opens it; right-click opens the actions menu.
|
||||||
|
onSessionCellClick(event: CellClickEvent): void {
|
||||||
|
if (event.type === 'contextmenu') {
|
||||||
|
event.originalEvent.preventDefault();
|
||||||
|
this.contextSession = event.dataItem;
|
||||||
|
this.sessionMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
|
||||||
|
} else {
|
||||||
|
this.openView(event.dataItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSessionMenuSelect(event: ContextMenuSelectEvent): void {
|
||||||
|
const session = this.contextSession;
|
||||||
|
if (!session) return;
|
||||||
|
if (event.item.text === 'View / 檢視') this.openView(session);
|
||||||
|
else if (event.item.text === '修改主日人數') this.openAttendanceEdit(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the attendance editor, prefilling the three age groups from the existing row (zeros if none).
|
||||||
|
openAttendanceEdit(session: OfferingSessionListItemDto): void {
|
||||||
|
this.attDate = session.sessionDate;
|
||||||
|
this.attForm = { adult: 0, youth: 0, kid: 0 };
|
||||||
|
this.attSaving = false;
|
||||||
|
this.attDialogOpen = true;
|
||||||
|
this.mealAttendanceApi.getRange(session.sessionDate, session.sessionDate).subscribe(rows => {
|
||||||
|
const row = rows[0];
|
||||||
|
if (row) this.attForm = { adult: row.adult, youth: row.youth, kid: row.kid };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAttendance(): void {
|
||||||
|
if (!this.attDate) return;
|
||||||
|
const date = this.attDate;
|
||||||
|
this.attSaving = true;
|
||||||
|
this.mealAttendanceApi.setCounts(date, this.attForm).subscribe({
|
||||||
|
next: counts => {
|
||||||
|
const total = counts.adult + counts.youth + counts.kid;
|
||||||
|
const row = this.sessions.find(s => s.sessionDate === date);
|
||||||
|
if (row) row.sundayAttendanceCount = total;
|
||||||
|
this.attDialogOpen = false;
|
||||||
|
this.attSaving = false;
|
||||||
|
},
|
||||||
|
error: (err: { error?: { message?: string } }) => {
|
||||||
|
this.attSaving = false;
|
||||||
|
alert(err?.error?.message ?? 'Save failed.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the Recent Sessions grid markup (component.html)**
|
||||||
|
|
||||||
|
Replace the whole `<kendo-grid class="lined" ...>...</kendo-grid>` block (lines 39-62) with:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<kendo-grid class="lined clickable-rows" [data]="sessions" (cellClick)="onSessionCellClick($event)">
|
||||||
|
<kendo-grid-column field="sessionDate" title="Date" [width]="120"></kendo-grid-column>
|
||||||
|
<kendo-grid-column title="Status" [width]="130">
|
||||||
|
<ng-template kendoGridCellTemplate let-s>
|
||||||
|
<span class="pill" [ngClass]="'pill--' + s.status.toLowerCase()">{{ s.status }}</span>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
<kendo-grid-column field="lineCount" title="Lines" [width]="80"></kendo-grid-column>
|
||||||
|
<kendo-grid-column title="Attendance · 主日人數" [width]="140">
|
||||||
|
<ng-template kendoGridCellTemplate let-s>{{ s.sundayAttendanceCount ?? '—' }}</ng-template>
|
||||||
|
</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="difference" title="Diff" [width]="110" format="c2"></kendo-grid-column>
|
||||||
|
<ng-template kendoGridNoRecordsTemplate>
|
||||||
|
<div class="empty">No sessions yet — pick a date above to start.<br><span>尚無紀錄 — 選擇上方日期開始</span></div>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid>
|
||||||
|
<kendo-contextmenu #sessionMenu [items]="sessionMenuItems" (select)="onSessionMenuSelect($event)"></kendo-contextmenu>
|
||||||
|
<div class="hint-text-sm">點一列檢視 · 右鍵修改主日人數 / Click a row to view · right-click to edit attendance</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
(The old inline "View" action column is removed — View is now a left-click and a context-menu item.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the attendance edit dialog (component.html)**
|
||||||
|
|
||||||
|
Add this dialog at the end of the file, just before the final closing `</div>` of the `.off` container (after the existing view-mode/`workspace` blocks and any existing dialogs):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- ============================ EDIT SUNDAY ATTENDANCE ============================ -->
|
||||||
|
<kendo-dialog *ngIf="attDialogOpen" title="修改主日參加人數 · Edit Sunday Attendance"
|
||||||
|
(close)="attDialogOpen = false" [width]="440" [maxWidth]="'95vw'">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
|
||||||
|
<label class="flex flex-col gap-1">成人 Adult
|
||||||
|
<kendo-numerictextbox [(ngModel)]="attForm.adult" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">青年 Youth
|
||||||
|
<kendo-numerictextbox [(ngModel)]="attForm.youth" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">兒童 Kid
|
||||||
|
<kendo-numerictextbox [(ngModel)]="attForm.kid" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="att-total">總數 Total: {{ attTotal }}</div>
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="attDialogOpen = false">Cancel</button>
|
||||||
|
<button kendoButton themeColor="primary" [disabled]="attSaving" (click)="saveAttendance()">Save</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add minimal styles (component.scss)**
|
||||||
|
|
||||||
|
Append to `APP/.../offering-session-page.component.scss`:
|
||||||
|
|
||||||
|
```scss
|
||||||
|
.clickable-rows {
|
||||||
|
.k-grid-table tr { cursor: pointer; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-total {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build the frontend to verify it compiles**
|
||||||
|
|
||||||
|
Run from `APP/`: `npm run build`
|
||||||
|
Expected: Build completes with no template/TS errors. (Per project convention, the scoped unit-test runner can't load this component's external `.html` template, so verification is via build + manual preview rather than a component unit test.)
|
||||||
|
|
||||||
|
- [ ] **Step 7: Manual verification (preview)**
|
||||||
|
|
||||||
|
Start the app, open finance/offering-session landing. Confirm:
|
||||||
|
- The Recent Sessions grid shows an `Attendance · 主日人數` column (a number for dates with a MealAttendance row, `—` otherwise).
|
||||||
|
- Left-click a row opens the read-only session view (unchanged behaviour).
|
||||||
|
- Right-click a row shows a menu with `View / 檢視` and `修改主日人數`.
|
||||||
|
- `修改主日人數` opens a dialog with three numeric fields prefilled from the day's counts, a live Total, and Save persists — the grid cell updates to the new total without a full reload.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add APP/src/app/features/giving/pages/offering-session-page/
|
||||||
|
git commit -m "feat(giving): show Sunday attendance per session and add edit action"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
- **Spec coverage:** 顯示總數 → Task 3 (backend) + Task 5 step 3 (column). 修改 Action → Task 1/2 (backend write path) + Task 5 (context menu + dialog). 右鍵 context menu / Date 可點 → Task 5 steps 2-3. 沿用 MealAttendance、三分類編輯、nullable 顯示 `—`、REST(非 SignalR)寫入 → 全部涵蓋。
|
||||||
|
- **Out of scope (per spec):** Recent Sessions grid 的手機卡片版未一併重構;optional SignalR 廣播(date == ServiceDay 時同步即時計數器)未實作。
|
||||||
|
- **Type consistency:** `SetCountsAsync(DateOnly, int, int, int)` 簽名在 interface / impl / controller / 前端 `setCounts(date, {adult,youth,kid})` 一致;`sundayAttendanceCount` 在 DTO(C# `SundayAttendanceCount`)與前端 model 對應;`AttendanceCounts` 前端模型已有 `adult/youth/kid`。
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Offering Session — 顯示與修改主日參加人數
|
||||||
|
|
||||||
|
Date: 2026-06-24
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
在 finance/offering-session 的 **Recent Sessions** 表格中,每筆 session 顯示該日的「主日參加人數」總數,並新增一個 Action 可修改該日的參加人數。
|
||||||
|
|
||||||
|
## Confirmed Decisions
|
||||||
|
|
||||||
|
1. **資料來源** — 沿用既有 `MealAttendance`。主日參加人數 = 該日 `AdultCount + YouthCount + KidCount`。修改 Action 改的是同一筆 `MealAttendance` 紀錄。
|
||||||
|
2. **顯示** — 後端 join,DTO 加 nullable 欄位;該日無紀錄時顯示 `—`。
|
||||||
|
3. **編輯介面** — Kendo Dialog 分別編輯三個分類(成人/青年/兒童),總數即時計算。
|
||||||
|
4. **Action 擺放** — 依既有慣例,Date 欄位可點擊觸發 View;View 與「修改主日人數」都進右鍵 context menu(沿用 expense-categories 範式)。
|
||||||
|
|
||||||
|
## Key Constraint
|
||||||
|
|
||||||
|
`AttendanceHub.SetCount` 只作用在 `ServiceDay`(本週日),無法改任意日期。因此編輯過去某場 session 的日期,**必須走新的 REST 端點**,不可用 SignalR。
|
||||||
|
|
||||||
|
## Backend Changes
|
||||||
|
|
||||||
|
### 1. DTO
|
||||||
|
`API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs`
|
||||||
|
- 新增 `public int? SundayAttendanceCount { get; set; }`(nullable:該日無 attendance row 為 null)。
|
||||||
|
|
||||||
|
### 2. OfferingSessionService.GetPaged
|
||||||
|
- 取得當頁 sessions 後,以這些 `SessionDate` 一次查 `MealAttendance`(單一 set-based 查詢,依日期分組求和),把總數填入各 DTO 的 `SundayAttendanceCount`;無紀錄者留 null。
|
||||||
|
- 不可對每筆 session 各發一次查詢(避免 N+1)。
|
||||||
|
|
||||||
|
### 3. IMealAttendanceService + MealAttendanceService
|
||||||
|
- 新增 `Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid)`。
|
||||||
|
- 沿用既有 clamp-at-zero 語意,一次寫三欄並回傳 `AttendanceCountsDto`;該日無 row 則建立(沿用 `GetOrCreateAsync` 的建立邏輯)。
|
||||||
|
|
||||||
|
### 4. MealAttendanceController
|
||||||
|
- 新增 `PUT /api/meal-attendance/{date}`,`[Authorize]`(與既有 `GetRange` 一致),body `{ adult, youth, kid }` → 回傳 `AttendanceCountsDto`。
|
||||||
|
- **Optional(plan 階段決定)**:若 `date == ServiceDay`,順手透過 `AttendanceHub` 廣播 `ReceiveCounts`,讓正在跑的即時計數器同步。預設先不做,避免增加耦合。
|
||||||
|
|
||||||
|
## Frontend Changes
|
||||||
|
|
||||||
|
### 1. Model
|
||||||
|
`APP/.../giving/models/giving.model.ts`
|
||||||
|
- `OfferingSessionListItemDto` 加 `sundayAttendanceCount?: number | null`。
|
||||||
|
|
||||||
|
### 2. Attendance API service
|
||||||
|
`APP/.../meal-attendance/services/meal-attendance-api.service.ts`
|
||||||
|
- 新增 `setCounts(date: string, counts: { adult: number; youth: number; kid: number }): Observable<AttendanceCounts>` → 呼叫 `PUT /api/meal-attendance/{date}`。
|
||||||
|
|
||||||
|
### 3. offering-session-page component
|
||||||
|
`APP/.../giving/pages/offering-session-page/offering-session-page.component.{ts,html}`
|
||||||
|
- imports 加 `ContextMenuModule`、`DialogsModule`、`InputsModule`(NumericTextBox)。
|
||||||
|
- Recent Sessions grid:
|
||||||
|
- 在 "Lines" 後新增欄 `Attendance · 主日人數`,顯示 `s.sundayAttendanceCount ?? '—'`。
|
||||||
|
- 移除原本獨立的 "View" 按鈕欄;改為 `(cellClick)`:`event.type === 'contextmenu'` → 開 context menu;否則 `openView`。Date 欄加可點擊樣式(`clickable-rows`)。
|
||||||
|
- `kendo-contextmenu`,items:`View`、`修改主日人數`。
|
||||||
|
- 修改主日人數 Dialog:
|
||||||
|
- 開啟時以 `mealAttendanceApi.getRange(date, date)` 取該日 breakdown 預填(無紀錄則三欄為 0)。
|
||||||
|
- 三個 `kendo-numerictextbox`(成人/青年/兒童,min 0),即時顯示總數。
|
||||||
|
- Save → `setCounts(date, …)`;成功後就地把該列 `sundayAttendanceCount` 更新為三者之和。Cancel 關閉。
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
PUT 端點用 `[Authorize]`,與既有 `GetRange` 一致。
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
- Recent Sessions grid 目前尚無手機卡片版;本次只新增欄位與 action,不一併重構 mobile 版面(可另開 task)。
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- Backend:`SetCountsAsync` 對負值 clamp 為 0、該日無 row 時建立;`GetPaged` 正確帶入 attendance 總數且無 N+1。
|
||||||
|
- Frontend:dialog 總數計算(成人+青年+兒童)與存檔後就地更新該列。(前端測試環境較脆弱,採最小範圍 inline-template 測試。)
|
||||||
Reference in New Issue
Block a user